How I’d improve the UIScrollView.contentInset API

UIScrollView.contentInset is conceptually a very simple API: it extends the scrollable range beyond that implied by the contentSize property. (The objc.io magazine has a great article on UIScrollView that explains contentInset in terms of simple arithmetic.) Its two main use cases are avoiding the keyboard and correctly underlapping iOS 7+’s translucent bars. But if you want to get more complicated than a navbar and a keyboard, the simple nature of contentInset makes things difficult.

Let’s say you’ve got a diagramming app whose interface consists entirely of a UIScrollView subclass representing a canvas. One of the object types that can be placed on the canvas is text. Conceptually, there’s no reason the canvas can’t manage keyboard avoidance by itself: it’s already in charge of its own appearance and implements all the other behaviors of text editing. So it listens for UIKeyboardWillShowNotification and updates its contentInset to move content out from underneath the keyboard. With a little bit of coordinate system transformation, it can correctly adjust itself no matter where it lives on screen—even in a popover!—exhibiting the reusability that we want from all of our object-oriented designs.

Now you want to add a navbar at the top of the view, which entails embedding the canvas’s view controller within a navigation controller. UIViewController has special magic for updating the contentInset.top of a scroll view inside of a navigation controller: if the view controller’s automaticallyAdjustsScrollViewInsets property is set to YES, then the view controller will set the scroll view’s contentInset.top so the background correctly underlaps the navigation bar. The keyboard always* comes from the bottom, and the navbar is always* at the top, so though it might feel a little weird for the same state to be managed by two different objects at two different tiers of the MVC model, the view controller’s management of contentInset.top never conflicts with the view’s self-management of contentInset.bottom.

* There’s no reason to assume this will be true forever.

Now you want to add a toolbar at the bottom of the window to contain buttons that switch between drawing tools. (If the keyboard appears, it should cover up the toolbar.) Now is when the trouble starts. The canvas must adjust its contentInset.bottom to correctly underlap the keyboard if it’s up or the drawing toolbar if it’s not. How does it know what value to use to underlap the toolbar?

One possible approach would be to make the toolbar a subview of the scroll view, but that’s unorthodox for a number of reasons: it breaks rendering of the toolbar’s background (a subview doesn’t blur its superview’s content), but more importantly it involves putting a lot of tedious code in the scroll view’s override of -layoutSubviews to do a whole bunch of anti-scrolling work that is a non-issue if the toolbar is a peer of the scroll view rather than its subview.

So the traditional approach wins out: you make the toolbar a peer of the scroll view, managed by the same view controller. The view controller could inform the canvas of the size of the toolbar, which it would incorporate into its calculation of contentInset.bottom. Add more properties for each new overlapping UI element (maybe you’d like an inspector bar attached to the navbar), all of which duplicate state that is already held in the view hierarchy itself! At some point it feels weird to have so much state being duplicated just so that the view can dodge the keyboard on its own, and it becomes really tempting to move management of all contentInset values up to the view controller.

This duplication feels particularly egregious because keyboard avoidance doesn’t feel like it needs to be a controller-level responsibility. The controller only needs to get involved to manage the interaction of multiple views that it manages. Every view knows just as much about the keyboard as every view controller. It would be great if the controller could just define the interaction between views and leave internal management up to the views themselves.

It turns out we already have a very flexible tool to express relationships within and between views: auto layout! In fact, iOS 8’s new margin-relative constraints hint that this is a promising direction. But in this case, rather than constraining views relative to a boundary, we’d be constraining the contentInset itself.

In this scheme, UIScrollView would support four new values for NSLayoutAttribute: NSLayoutAttribute{Left,Top,Right,Bottom}ContentInset. UIScrollView would also have four constraint priority properties, each of which specifies the priority at which the scroll view attempts to set its contentInset on that side to 0. For backwards compatibility, this priority would default to 0 so that apps which manually set the contentInset don’t see a change in behavior.

Additionally, UIWindow would gain a new attribute: NSLayoutAttributeKeyboardTop. This value represents the distance between the bottom of the window and the top of a docked keyboard (or, if it has an accessory view, the top of the accessory view if the keyboard is docked or split).

Here’s how we’d constrain the top and bottom contentInset in our diagramming app:

The scroll view could be in charge of setting up its own keyboard avoidance constraints in -viewDidMoveToWindow. In fact, the framework could even expose this feature for every scroll view with a new UIScrollView.automaticallyAvoidsKeyboard property. Framework clients only have to perform customization specific to their application, and even that customization requires minimal effort (adding constraints) for even better results (properly avoiding the keyboard anywhere in the window).

It would also make custom presentations that intelligently center themselves on screen much easier to set up:

With the help of a custom container view, we can avoid the keyboard quite easily:

Here’s hoping.

One Comment

Comments are closed.