13 Aug 2011

Dynamic Layout in UIKit

UI elements can be laid out in two different ways:
  • Top-down, where parent views determine child views' size. This works well for fixed-size interface elements like toolbars. Cocoa supports this kind of layout well, both at the framework level and as nibs that can be laid out graphically.
  • Bottom-up, where child views' size affects parent views' size. This is needed for dynamic layout, where you don't know the content ahead of time, or how much space it needs on screen. For instance, think of a news article — the headline can take one or two lines, and the article text needs to be positioned based on where the headline ends. Dynamic layout requires that a view's size is determined by its content, and superviews' size by their children's. HTML supports this natively, but not Cocoa.
As an aside, it's interesting that Android supports both — a view's size can be defined to be small enough to just fit its content, or expand to take up all available space.

So here's how we do dynamic layout in Cocoa. These took me a month to figure out, so you'll hopefully save that time.

1. Avoid changing your size in layoutSubviews. Since doing so triggers another call to layoutSubviews, that’s an easy way to produce a layout cycle, where layout happens over and over again. Sometimes it’s infinite, and sometimes it converges and stops eventually, but that kills performance, making the app slow to respond to user actions, making the scrolling jittery, etc. Instead of changing your size in layoutSubviews, override setBounds and setFrame to set it to the right value to begin with.

This is a special case of a more general rule — you never want a case where multiple objects all directly set a given view’s size. Because then the size of the object will be unpredictable, and you can easily have layout cycles. Only one object in the entire app should be able to set a given view's size. And it's best to have that object be the view itself. If a view’s size depends on factors that are not known to it, pass that information in (or define a delegate) and let the view compute its size, in setBounds/setFrame, and override the passed-in size, rather than some other object doing the math and setting the size on the view.

Then anyone can invoke [view sizeToFit] on the subview and let it compute its size. Or invoke view.bounds = CGRectMake(view.bounds.origin.x, view.bounds.origin.y, randomSize.width, randomSize.height). randomSize can be any size, which is ignored by your view’s setBounds, as discussed above. But sizeToFit is a cleaner way of doing this.

2. If you have constraints on the parent (like maxWidth), make sure you propagate them down the hierarchy automatically. We had a bad bug where setting the maxWidth on the parent would behave differently from setting it on the children -- one of the two caused an infinite layout loop.

3. Another consequence of UIKit being designed for static layouts is that that parent views’ layoutSubviews methods are called before children’s. If your parent view’s depends on the child view’s layout, you want the child’s layoutSubviews to run first (just in case it changes its size). To do this, you define a CALayer subclass, override layoutSublayers, and invoke UIView’s layoutSubviews bottom-up.

4. Trick to catch layout cycles: If a view’s layout depends on its children, you want to assert that the converse is not true, because that can cause a layout cycle. Have a rule that says that such a class’s layoutSubviews cannot change the frame or bounds of subviews. In addition to manually doing this, you can automate this check, by creating a list of children that need layout, before and after the parent is laid out, and assert that the “after” list does not have any additional views. This catches a finite layout cycle, too.

5. Override sizeThatFits on your dynamically laid out container class to invoke sizeThatFits on subclasses and sum it up. You should define this to conform to the UIView API, just in case some class you don’t control decides to invoke this function. But don’t depend on it yourself. In other words, make sure that the parent view’s layoutSubviews does not depend on the child view’s sizeThatFits, because then you’re exposing yourself to a bug where the child view has a bug that causes sizeThatFits to return a different value than is actually enforced by the class in layoutSubviews or setBounds/setFrame.

Instead, the parent should ask the child to lay itself out (see point 3 above) and then measure its actual size.

6. sizeThatFits is broken in some standard classes. We used a category that defines saneSizeThatFits in UIView that just calls sizeThatFits, and we override saneSizeThatFits in specific UIView classes that are known to be broken. Our code never invokes sizeThatFits; it always invokes saneSizeThatFits.

4 comments:

  1. Callum12:52 am

    Thanks for the post. Do you have any code you could share? I've been looking for something like a StackPanel, in xaml. UIStackPanel is good, but not quite robust enough to handle large nested views.

    ReplyDelete
  2. Sorry, I don't have code in a shareable form.

    ReplyDelete
  3. Jesse1:20 am

    Great post! Detail question: suppose someone changes a property of a child view that causes that view to return a different size (for example, you change the text on a button and the button grows to fit the text). How is a parent view notified of this change to know it needs to re-layout?

    ReplyDelete
  4. Glad to be of help, Jesse. Your question is a good one. I don't have a perfect answer; only a (hopefully) acceptable one: the setters of the child view that change its state should invoke [self.superview setNeedsLayout]. And our container class propagates the invalidate up the view hierarchy, until you reach a view whose layout doesn't depend on its children.

    This means that you can't expose say a UIButton directly to clients (say in the model or controller layers) and ask them to set its state, because the invalidation won't happen.

    So, if you have a NewsArticle view that has a share button as a subview, you wouldn't ask your clients to do:
    newsArticle.shareButton.title = @"Facebook";

    Instead you'd do:
    newsArticle.shareButtonTitle = @"Facebook";

    Here you'd define the shareButtonTitle setter to set the text and invalidate the parent.

    Hope that helps. Let me know if you think of a better way to do this.

    ReplyDelete