iOS
Adventures in iOS Programming: Self-Sizing Cells
Matt Neuburg
Written on March 4, 2021

The other day, I was talking with BiTE CTO Brant about what we’d been up to lately in our iOS programming endeavors. I suggested that it might be interesting if your computer had a way to quantify how much time you spend on different sorts of programming task in the course of developing a project. If that were possible, we both agreed, certain aspects of iOS programming would probably turn out to loom disproportionately large as sinkholes for time. And autolayout would very likely be at the top of the list.
In principle, autolayout is both extremely cool and absolutely necessary. A single iOS app can run on an astounding variety of screen sizes and aspect ratios. To cope with that, views need to adjust their frames to remain legible, usable, and nice-looking. An autolayout constraint is simply a way of encoding some rules about what should happen when layoutSubviews
is propagated down the view hierarchy to signal that the interface needs to adjust itself; behind the scenes, the runtime implements layoutSubviews
to read those autolayout constraints and to set the frames of views in accordance with what they say.
Writing simple constraints correctly is not very difficult, but things can get complicated when you have to take account of what the runtime is already doing for you. The Cocoa framework is a big black box, and it can be hard to understand what it is up to. To illustrate, here’s a little story about a difficulty I had recently with self-sizing table view cells.
How Self-Sizing Cells Work
Back in the day, one of the hardest iOS programming problems was how to make the cells in your table view have different heights from one another. It was possible to do, in theory; you just had to implement the delegate method tableView(_:heightForRowAt:)
to return the correct height for each row of the table view. But arriving at that height, based on the contents of the cell, was extremely tricky. You had to work out what the height should be, and you had to work it out at the right time so as to be ready to respond to that delegate method when it was called.
Consider, as a simple example, a table view cell containing just a single UILabel whose text can wrap. Let’s suppose the text to be displayed is of different lengths for different rows of the table — and will therefore occupy a different height in each cell. A label whose numberOfLines
is 0
is allowed to wrap its text and grow or shrink to accommodate that text exactly. So the label knows how to change its height correctly, but how and when are we going to find out what that height is so that we can tell the cell what its height needs to be?
With great difficulty, I worked out a robust if complex solution to this problem and my apps used it for several years. But then autolayout was invented, and soon afterward, self-sizing cells became automatic. The rule nowadays is that if the table view’s rowHeight
and estimatedRowHeight
are set to UITableView.automaticDimension
, the runtime will size the cell based on the autolayout of its contents, performed from the inside out. If we just pin our UILabel on all four sides to the cell’s contentView
, the cell will adopt the correct height to display the label’s text exactly, all by itself.
Rolling Your Own
Now let’s say we have a cell that contains a custom UIView whose height can vary depending upon its contents. How do we make that kind of cell self-sizing?
To illustrate the problem, I’ll devise a custom view that does much the same sort of thing that a UILabel does. It has an attributedText
property that’s an NSAttributedString; the view draws that attributed string within itself:
class StringDrawer: UIView {
@NSCopying var attributedText = NSAttributedString() {
didSet {
self.setNeedsDisplay()
}
}
override func draw(_ rect: CGRect) {
self.attributedText.draw(with: rect, options: [.truncatesLastVisibleLine, .usesLineFragmentOrigin], context: nil)
}
}
How is this view going to communicate to the surrounding table view cell what its height is supposed to be, based on the text that it is displaying?
To answer that question, let’s ask ourselves how a UILabel does the same thing. The answer is that a UILabel has an intrinsicContentSize
read-only property, which is a CGSize. The autolayout engine consults this property and uses the result to form internal height and width constraints for the view at a lowered priority. Every time a UILabel’s text
or font
or attributedText
changes — anything that might alter the way the label draws its contents — the label updates its intrinsicContentSize
to reflect the size of that drawing. Thus, a cell whose height depends on the height of the label’s intrinsicContentSize
will be sized correctly to display the label’s text.
Well, what UILabel can do, we can do. intrinsicContentSize
is a UIView property. So we, too, can implement a getter for the intrinsicContentSize
property! We will also need a way to notify the world that our intrinsicContentSize
has changed so that layout can be performed afresh when our text changes; a UIView has an invalidateIntrinsicContentSize
method to allow us to do just that. Our custom view now looks like this:
class StringDrawer: UIView {
@NSCopying var attributedText = NSAttributedString() {
didSet {
self.setNeedsDisplay()
self.invalidateIntrinsicContentSize()
}
}
override func draw(_ rect: CGRect) {
self.attributedText.draw(with: rect, options: [.truncatesLastVisibleLine, .usesLineFragmentOrigin], context: nil)
}
override var intrinsicContentSize: CGSize {
let measuredSize = self.attributedText.boundingRect(
with: CGSize(width:self.bounds.width, height:10000),
options: [.truncatesLastVisibleLine, .usesLineFragmentOrigin],
context: nil).size
return CGSize(width: UIView.noIntrinsicMetric, height: measuredSize.height.rounded(.up) + 5)
}
}
As you can see, I’ve allowed a little extra space at the bottom. Apart from that, our content size is simply the bounding rect of our text, based on our own width.
Unfortunately…
Okay, so we now rip the UILabel out of the cell and substitute a StringDrawer. Our table view data source sets the StringDrawer’s attributedText
for that row. We launch the app, and what do we get?
Well, it isn’t quite right, is it? As you can see, there is some extra space at the bottom of the cells. You might think that this is because we’ve made some simple arithmetic mistake — but here’s the kicker. If you now scroll those cells out of sight and then scroll them back into view, they look perfect! This proves that our code is, in fact, correct. So what’s the problem?
When I’m faced with this kind of mystery coming from the Cocoa runtime, I fall back on what I call “caveman debugging”: I stick in lots and lots of print
statements and run the app and see what the numbers are. And when I do that, here’s what I find out: for just the table view cells that are initially visible when the table view first appears, our intrinsicContentSize
getter is being called with the wrong value for self.bounds.width
!
In other words, at the time our intrinsicContentSize
is first called, autolayout hasn’t finished yet. Our bounds width is, therefore, not the bounds width we are really going to have when the user sees us. In particular, the bounds width we are using is too narrow — and therefore, the height we are supplying is too tall. Hence the extra white space.
Mystery #1: How To Encourage Layout
So now we know what the problem is, but we don’t really know why the problem is or what we can do about it. Is this some sort of bug in the autolayout engine? I don’t like to blame Cocoa, but I rather think it is a bug, because when I use print
to log both the bounds
of the StringDrawer and the bounds
of its superview, I can see that the superview bounds
are correct!
print(self.bounds.width) // 300.0
print(self.superview!.bounds.width) // 375.0
I’m running this test on an iPhone SE 2 simulator, so 375.0
is the width of the screen, the width of the table view, the width of the table cells, and the width of the table view cell content views. But it is not, at this moment, the width of the StringDrawer, even though the StringDrawer is pinned on all sides to the content view.
So we’ve got some sort of weird autolayout timing problem. It’s as if autolayout is only partially complete! The table view is right, the cell is right, but the StringDrawer isn’t right yet.
This is why autolayout is such a sinkhole for time! You never know when you’re going to run into an issue like this, and when you do, you can spend literally days tracking it down. And in fact, that’s just what happened to me. I encountered the issue; I banged around with my code for two days, mystified; finally, in desperation, I reduced the problem to a very small project to encapsulate the issue.
I tried everything I could think of to solve the problem. I tried calling layoutIfNeeded
(that was Brant’s idea). I tried calling reloadData
. I tried calling reloadData
twice. Finally, I came up with the following mysterious, hacky trick in my view controller (which is a table view controller):
var didInitialLayout = false
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
if !didInitialLayout {
didInitialLayout = true
UIView.performWithoutAnimation {
self.tableView.beginUpdates()
self.tableView.endUpdates()
}
}
}
Having added that code, my table view now looks perfect once again!
Do you see what I did there?
-
First, I need to wait until layout is possible, so I implement
viewWillLayoutSubviews
. This event is sent to us when the view hierarchy is completely assembled — the table view is in the window — but before the user sees anything. So we can make an adjustment to the layout without causing a visible jump in the interface. -
Second, I only want to make this adjustment once, the very first time
viewWillLayoutSubviews
is called. So I use a Bool flag to make sure my code can’t run multiple times. (That’s an ugly, hacky trick right there, but I don’t know a better way.) -
Finally, I tell the table to ask for its internal measurements to be performed afresh. That’s what the calls to
beginUpdates
andendUpdates
are doing. I don’t want any change in dimensions to be animated, so I’ve wrapped those calls in aperformWithoutAnimation
block.
The outcome is that my StringDrawer views’ intrinsicContentSize
getters are still being called too early and are still returning the wrong height value, but then viewWillLayoutSubviews
comes along and causes their intrinsicContentSize
getters to be called again, and this time self.bounds
is correct, and the answer is right.
I’m not at all happy with this approach. For one thing, if I were to switch to using a diffable data source, an attempt to call beginUpdates
or endUpdates
will crash my app. Plus, it’s wasteful; layout is relatively expensive, and I’m forcing it to happen twice. And also, it’s hacky! I’m just guessing, based on some quirk of the timing, that this is going to work. It does work, but it seems awfully fragile; whether it will always work, on every machine or on every iOS system going forward, is anybody’s guess.
Mystery #2: How On Earth Does UILabel Work?
But this is the real kicker. Why didn’t I have the very same problem when I was using a UILabel? I mean, if my StringDrawer doesn’t know correctly what its width is the first time that its intrinsicContentSize
is called, why does a UILabel know correctly what its width is the first time that its intrinsicContentSize
is called?
I’m not being coy here; I truly have no idea what the answer to that question is. I suspect that UILabel, since it belongs to Apple, is being given the benefit of some sort of special treatment. Perhaps a UILabel in a UITableView receives a special dispensation in the form of an “early” layout. Maybe Apple discovered the very same issue and optimized it away somehow, just for its own views — leaving the rest of us who write our own views with an intrinsicContentSize
to fend for ourselves.
In closing, let me return to the theme with which I began. This entire endeavor has been a big fat waste of my time! My StringDrawer class should just work; I shouldn’t have to spend days experimenting in an attempt to compensate for the mysterious undocumented behavior of autolayout. That’s what happens, though, when big complicated stuff is happening behind the scenes. The iOS runtime is a gorilla, and you can’t be too surprised, I suppose, when every once in a while, it takes it into its head to slap you across the room.