Mastodon

Using Stack Views in Table Cells

One of the cool things UIStackView can do for you is make it easy to dynamically update your app’s user interface while it’s running, with smooth animations and not a lot of code. My recent talk at iOSDevCamp DC covered some techniques. Natasha the Robot wrote a couple of great posts based on my talk, and today I’m going to talk about another unexpected (to me?) use of stack views.

Animated Updates with Stack Views

Stack views exist to figure out the layout constraints for their arranged subviews. But only for the stack views that are visible. It might seem obvious but stack view layouts don’t consider subviews that don’t appear on the screen.

The great thing about this is that you can dynamically update your UI just by changing the value of the isHidden property on an arranged subview. Change the value and the stack view recalculates the layout for its new collection of visible subviews and updates the UI as needed. These updates happen instantly but they can be wrapped in a UIView animation block to smooth them out.

If you hide a view like this it conveniently disappears from the layout, but it’s still a subview of the stack view. So you don’t have to do anything special if you want to bring it back later on. It disappears but doesn’t get removed from the view hierarchy or deallocated.

So yeah, if you have a button in a stack view you can make it disappear and update a whole mess of layout constraints to match the new button-free layout just by doing this:

button.isHidden = !button.isHidden

And if you want to make it nice and smooth and animated it’s not much harder:

UIView.animate(withDuration: 0.3) {
    button.isHidden = !button.isHidden
}

That syntax seems a little weird to me since animations usually imply gradually changing the value of something. Here the value is a Boolean, though. It’s not that the latest version of Swift added intermediate Boolean values, this just tells the layout system to animate the updates on the screen.

Designing Expandable Table Cells

So let’s apply these automatic layout updates to a common design pattern in iOS apps: the table cell that when tapped expands to show more content for the selected table entry. As demo I wrote an app that gets the top 25 tracks from the iTunes store and shows them in a table. If you tap on one of the cells it expands to show audio playback controls that could be used to play a preview of the track. (I didn’t actually implement audio playback because I wrote this to demonstrate the dynamic UI).

Incidentally, although this is a common design pattern, the actual UI above is an example of why engineers generally should not be allowed to do UI design.

The table cells are designed with a stack view as the only subview of the cell’s content view:

The stack view has two arranged subviews. The top subview shows track information and is always visible while the bottom audio playback view is only visible when the cell is expanded. Both of these are just containers, with the image, labels, buttons, etc laid out using traditional autolayout constraints.

There are a couple of things I should clarify about how this works:

  • The stack view distribution is “fill”, so it needs to know how tall its arranged subviews are to determine its own height. Since this stack view’s arranged views don’t provide an intrinsic content size (unlike labels or buttons, which do) both of them have constant height constraints.

  • I set the table view’s cell height to be large enough to hold everything contained in the stack view. This just makes Xcode leave enough space for me to add everything that could possibly show. It won’t affect the run-time sizing, for reasons discussed a little later on.

Making it Happen

The first step is about getting the table view to notice that cells can expand and collapse. Expanded cells will be taller than collapsed cells, so the table needs to be ready to get the cell height from the cell. So turn on self-sizing cells for the table view.

tableView.rowHeight = UITableViewAutomaticDimension
tableView.estimatedRowHeight = 85

The estimatedRowHeight value is the height of the top part of the expandable cell, i.e. the part that’s always visible. This is why setting the cell height above won’t affect the app when it runs– because I’m telling the table view that it should ignore that size and ask each cell how tall it wants to be.

Since I want to make the audio playback view appear and disappear, the table view cell class has an outlet pointing to it called audioPlaybackView. Following the discussion above about how stack views only arrange their visible subviews, the expand/collapse behavior is simply a matter of showing or hiding audioPlaybackView and then getting the table view to notice the change.

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    if let cell = tableView.cellForRow(at: indexPath) as? StoreTrackTableViewCell {
        cell.audioPlaybackView.isHidden = !cell.audioPlaybackView.isHidden
        tableView.beginUpdates()
        tableView.endUpdates()
        tableView.deselectRow(at: indexPath, animated: true)
    }
}

The beginUpdates/endUpdates shuffle there is included to make the table view notice that the cell height has changed.

Finally, I want the cells to start off in their collapsed state. When the table view loads, no cells should be expanded. That’s easy to do with a property observer on the IBOutlet for the audio playback view.

@IBOutlet weak var audioPlaybackView: UIView! {
    didSet {
        audioPlaybackView.isHidden = true
    }
}

One other detail you’ll probably want is to track which cells are currently expanded, so that table cell reuse doesn’t unexpectedly expand multiple rows during scrolling. I won’t cover that here but it’s in the sample project.

Yay it works! But wait…

At this point my project worked, but I was getting a bunch of the dreaded Unable to simultaneously satisfy constraints messages when I ran the app.

2016-08-04 14:31:42.343368 Top25[23475:3079180] [LayoutConstraints] Unable to simultaneously satisfy constraints.
	Probably at least one of the constraints in the following list is one you don't want. 
	Try this: 
		(1) look at each constraint and try to figure out which you don't expect; 
		(2) find the code that added the unwanted constraint or constraints and fix it. 
(
    "<NSLayoutConstraint:0x600000289ab0 UIView:0x7fed2360c860.height == + 51   (active)>",
    "<NSLayoutConstraint:0x608000285e60 'UISV-hiding' UIView:0x7fed2360c860.height ==   (active)>"
)

Will attempt to recover by breaking constraint 
<NSLayoutConstraint:0x600000289ab0 UIView:0x7fed2360c860.height == + 51   (active)>

The first constraint was immediately familiar– it’s the fixed height constraint of 51 on the audioPlaybackView for each cell. The second one I didn’t recognize, but its name gave me a clue. UISV-hiding, you say? And it conflicts with the fixed height constraint on a view that I’m hiding. And I’m getting one of these errors per visible cell. Hmmm….

These messages give some insight into how stack views manage hidden arranged subviews. It’s apparent that one of the stack view’s changes is to apply an extra height constraint (presumably of height 0) to make the view disappear from its layout. One that in this case conflicts with the fixed height constraint.

Knowing that, the fix is straightforward. When the audio playback view is hidden I don’t care what its height is. It’s not visible so who cares. So I’ll turn down the priority on the height constraint just a bit so that the stack view’s hiding constraint will take precedence.

And now, all is well. The cells expand and collapse when tapped and the layout engine doesn’t have any reason to complain about it.

Other possibilities (a.k.a. exercises for the reader)

This example implements a simple expandable table cell, but it could be extedned in some interesting ways.

  • The stack view might contain three or more subviews, which could be switched in and out as needed. Maybe the audio playback controls would be visible when the cell loaded, but could be replaced by a different subview in some situations.

  • The contents of each cell section could be redone to use stack views as well, making it easy to update their contents on the fly. For example the “play” button in the audio playback view might be replaced with a UIActivityIndicatorView while the audio preview is loading. It could then be replaced with the “play” button when loading finished.

Demo Project

The code used for this post can be found at GitHub.