I’ve noticed a lot of people online (i.e. StackOverflow) that jump to a handful of UIViewController properties when their views aren’t laying out properly, then manipulate them almost randomly to get the behavior they want. This invariably leads to the “accepted” answer to layout issue being “set automaticallyAdjutstsScrollViewInsets to [true/false]” or “set edgesForExtendedLayout to UIRectEdgeZero” with one person saying “thanks, that fixed it!” and five others saying “that didn’t fix it pls help”. Even in Apple’s WWDC videos there’s no super clear explanation of how these APIs work and what effects they have on each other, although there are broad overviews in WWDC 2013, when they were unveiled. I couldn’t find any great write-ups on how it all works, so I wanted to put out a PSA on how exactly these APIs work so we can start fixing things the correct way and stop perpetuating bad info.
I’m of the opinion that sample applications with interactive properties are absolutely the best way to learn the ins and outs of UIKit. If you play with one enough you can start to predict behavior; once you’re at the point where you can explain what changing a given property will do to the resulting layout, you’ve got a pretty decent grasp. So, to illustrate how this all works, I’ll include screenshots from a sample application, then provide a place for you to stop and think about what effect changing a given property will have. Of course, you can just skim through, but I think you’ll find it a lot more advantageous to try and figure the resulting layout ahead of time.
For the purposes of this, we’re going to start off with iOS 10, which allows us to ignore the effects of the newly introduce safeArea and the additional APIs (and considerations) it brings. We’ll cover those sometime in the near future.
Visualizing Extended Layout
Here’s everything with the default values that views/view controllers have upon instantiation and the default look that a tableView has when shown inside of a UINavigationController. Note that the tableView’s background is green, but cell backgrounds are red. We can use this to see where the tableView begins and where its content begins; when those two are different, we’re looking at a non-zero-contentInset, which is shown in the cell towards the bottom.
Where does this contentInset come from? If you check the code, we don’t specify one ourselves. Enter automaticallyAdjustsScrollViewInsets.
What is UIViewController’s automaticallyAdjustsScrollViewInsets?
The UIViewController property automaticallyAdjustsScrollViewInsets was introduced in iOS 7 (the version that overhauled the interface) and defaults to true. In iOS 7, navigation bars defaulted to translucent, and to show off the translucency, views had to begin underneath the nav bars (you can’t appreciate translucency if there’s no content to see there!). However, if views start behind the nav bar, things are obviously going to obscured without some Apple Magic™ to handle things for developers. This magic came in the form of automaticallyAdjustsScrollViewInsets, which goes into a UIViewController’s subviews and runs this test:
func magicView() -> UIView? {
guard self.isKind(of: UIScrollView.self) else {
return self.subviews.first?.shouldApplyMagic()
}
return self
}
and it automatically sets the contentInset of that scroll view to accommodate a navigationBar.
What is UIScrollView’s contentInset?
ContentInsets are pretty basic and I’m only including this section as a quick reminder for those that aren’t fully clear on the idea. I find that if you think of UIScrollViews as “windows” into content, it’s easier to understand what they’re doing; the window frame itself doesn’t change size, but you can move around to see different content behind the window frame. You can’t see more than is there, so once you hit the topmost/bottommost part of your content, you can’t go any further (ignore the bouncing UIKit gives us). Using the same metaphor, a contentInset essentially adds “blank” content above the thing you’re looking at, meaning you can see more than you otherwise would. If I add a top contentInset of 100 points, it means I can scroll to the top of my content, then scroll an additional 100 points above it. For the basic case of UITableView, this is just blankness that will show whatever your backgroundColor is, but UIScrollViews will let you do a lot more than just that. I won’t get into that now; maybe explaining UIScrollViews is a nice idea for another day.
Now that we have a decent understanding of automaticallyAdjustsScrollViewInsets, the initial state of our app should make sense. Our view starts at the top of the UINavigationController (well, its view). By default, Apple gives us automaticallyAdjustsScrollViewInsets == true, so when our view is laid out, it sees that our navigation bar has a height of 64 (it goes into the status bar area) and gives us 64 points of blank content above the tableView. In effect, this pushes our tableView down visually, which now lets us scroll behind the toolbar if we want, yet we don’t have to deal with the default state of our content being stuck behind our nav bar initially.
What is extended layout, and what is UIViewController’s edgesForExtendedLayout?
The snarky answer is that edgesForExtendedLayout is the property people set to .zero (or UIRectEdgeNone in Objective-C) when they can’t figure out why their views are offset incorrectly without really understanding what’s going on.
More accurately explained, “extended layout” is the name for the behavior that Apple introduced in iOS 7 along with their translucent-by-default navigation bars. The extended layout of a view refers to the parts of the view that can “stretch”. By default, edgesForExtendedLayout includes all edges. This should make sense given the default translucency in iOS 7. Apple, by default, sets all navigation bars to be translucent; it can’t take advantage of that if our views don’t “begin” behind the navigation bar. For tableViews, this is accomplished by pinning the view to the top of the navigationController’s view and then setting an appropriate contentInset on the table view. Quiz time!
To repeat, by default, we look like this:
Our navigation bar is translucent, our edgesForExtendedLayout includes all edges (the top is the relevant one here), and we automaticallyAdjustScrollView insets. As a result, we can see behind our navigation bar, our view extends to the top of the navigation bar, and the contentInset of the tableView is set to the height of the navigation bar.
What happens if we turn off automaticallyAdjustsScrollViewInsets? Cue the Jeopardy! music.
Our navigation bar is translucent, our edgesForExtendedLayout still includes the top edge, but our view controller is no longer looking to its subviews for a UIScrollView to set the contentInset of. As a result, we can see behind our navigation bar, our table view starts at the top of the navigation controller, but the content is not inset, so we’re stuck with an obscured view.
What if we turn don’t allow our top edge to be part of the extended layout?
Even though our navigation bar is translucent, we aren’t allowing our top edge to participate in the extended layout. As a result, our view is now pinned below the navigation bar. You can see that even if we scroll, we can’t see the cells behind the navigation bar; the “window frame”, to borrow an earlier metaphor, isn’t positioned behind the navigation bar anymore. Our contentInset is set to zero, as we no longer have to accommodate for starting behind the navigation bar.
What happens when we aren’t using translucent navigation bars?
I won’t bother providing pictures for this, as we have all of the context we need to figure this out. Assuming we’re starting from the default state again: automaticallyAdjustsScrollViewInsets has no effect, as our view will no longer start behind the navigation bar. Including our top edge in our edgesForExtendedLayout won’t have any effect either, as we have no layout to extend if we have a translucent nav bar. No matter what you change with those two properties, if our navigation bar is opaque/not translucent, our view will start below the navigation bar and our contentInset will never change.
What is extendedLayoutIncludesOpaqueBars?
So, we’ve established that extendedLayout is Apple’s way to move our views behind translucent navigation bars by default. In the previous section we discovered that once the translucent navigation bar is disabled, the extended layout is no longer in play for the top edge because there is no translucency. UIViewController’s extendedLayoutIncludesOpaqureBars, however, changes that logic, and the name should now be intuitive: if the property is set to true, the extended layout logic comes into play even if your navigation bar is not translucent.
Starting from the default state but with an opaque/non-translucent navigation bar, what should happen if we now tell UIKit that we want it to include opaque bars when determining how to layout our view with regards to extended layout? Hit the music once again…
That was somewhat of a trick question. Remember that automaticallyAdjustsScrollViewInsets defaults to true, so regardless of what happens with the extended layout, UIKit is going to change our contentInset to make sure our content visually begins below the navigation bar. Now, what happens when we turn that off? Recall that we’re essentially telling UIKit to pretend our navigation bar is translucent…
Yep, our view is now stuck behind our opaque navigation bar, as our contentInset is no longer changed for us. What happens if we no longer include our top edge in the extended layout?
Everything goes back to normal and our table view is laid out below, not behind, our navigation bar.
What if there’s no navigation bar at all?
Just to cover the situation that does not include a navigation bar at all (translucent or otherwise), the only properties that have any effect are automaticallyAdjustsScrollViewInsets, which determines if you’re offset below the status bar or not, and edgesforExtendedLayout, which determines if your view extends behind the status bar or not. If you’re in a situation where your view doesn’t touch the status bar, neither property has any effect.
What if I’m not using a UITableViewController/UIScrollView?
We should be relatively confident in how our UITableViews and UIScrollViews behave with extended layouts in iOS 10. What about non-UIScrollViews? How do stock UIViewControllers — with stock UIViews, of course — behave in the world of extended layout? For the purposes of this, I’m removing the “automaticallyAdjustsScrollViewInsets” configuration. We already know that the property will use our magicView() method above to find a scroll view to manipulate, but we won’t have one here.
Anyway, in this new UIScrollView-less world, there’s a few different things that can happen. If we pin our subviews to our topAnchor, we get this:
If we pin to our view controller’s topLayoutGuide, we get this:
That should somewhat clarify what the topLayoutGuide (or bottomLayoutGuide if we were using a toolbar) does if it wasn’t completely clear before.
What do the properties do at this point?
Showing/hiding the navigation bar will only change where our topLayoutGuide is; it’ll only cover the status bar:
What about edgesForExtendedLayout and extendedLayoutIncludesOpaqueBars if I’m not using a UIScrollView? We’re no longer manipulating a contentInset because we aren’t using a scroll view, but UIKit will do the next-best thing: it’ll manipulate our topLayoutGuide. What happens if our navigation bar is no longer translucent? Nothing moves, but our background color (in actuality it’s our view itself) no longer goes behind our navigation bar, and our topLayoutGuide is reset to zero as our entire view is moved below the navigation bar. What if we have a translucent navigation bar but don’t include our top edge in our extended layout? Our view no longer extends behind our navigation bar and our view is moved below the navigation bar. What if we have a translucent nav bar, our top edge is included in our extended layout, but we include opaque bars in our extended layout? In that case, our view will extend behind our opaque navigation bar (so we see nothing behind it), but our topLayoutGuide is adjusted to accommodate the navigationBar/status bar. Visually, there’s no difference, but behind the scenes, all of the properties are appropriately respected.
With all of the above explained, UIViewController’s automaticallyAdjustsScrollViewInsets, extendedLayoutIncludesOpaqueBars, and edgesForExtendedLayouts — as well as how they interact with translucent and non-translucent navigation bars — should now make more sense. We can see that UIScrollViews use contentInset to move views down and non-UIScrollViews will use moving layout guides to move views up/down accordingly. UITableViewControllers will constrain their UITableViews to the top of its layout guide, not bottom, so that we can scroll behind the navigation bar, but non-scroll views are pinned according to the various properties we’ve discussed.
As long as we’re appropriately constraining our views to layout guides and not just top anchors, our view controllers (and their associated views) should behave how Apple originally intended back in 2013 when iOS 7 was first released.
What if I’m using my own view controller/view, but I have a scroll view?
The behavior here is odd, but worth noting. As mentioned previously, automaticallyAdjustsScrollViewInsets will apply to whichever view passes the magicView() test above. If you have a subview that’s a scroll view that is getting affected, you likely want to constrain your scroll view to self.view.topAnchor and not self.topLayoutGuide.bottomAnchor to mimic Apple’s behavior, which is clearly what they want developers to use. As far as I can tell, there aren’t any special rules to consider here past what I’ve already described.
What about iOS 11 and later?
In iOS 11, Apple rolled out the concept of safeAreas, deprecating automaticallyAdjustsScrollViewInsets in the process. UIScrollView gained UIScrollViewContentInsetAdjustmentBehavior in the process, which has some important effects on how all of the above works. It’s important in my mind, though, that we understand where this logic started; once that’s firmly cemented in our minds, we can then view the new APIs as simply a delta from where it started as opposed to learning iOS 10 and iOS 11 behavior completely independently. I’ll have a post up explaining iOS 11’s behavior with everything we see above soon™. I’ll update this with a link to the new post when it’s published.
In Summary
- automaticallyAdjustsScrollViewInsets will apply if self.view is a UIScrollView, or if self.view.subviews is a UIScrollView, or if self.view.subviews.first.subviews is a UIScrollView, or if […]
- automaticallyAdjustsScrollViewInsets will add a contentInset of {navbarHeight} to whichever scrollview is affected by the property if extended layout is in effect for the top edge
- If (a view has extended layout for the top) and (the navigation bar is translucent or the extendedLayoutIncludesOpaqueBars is true), the view’s frame will extend underneath the navigation bar
- Extended layout (and its various rules/properties) on the top edge will adjust where the topLayoutGuide of the view controller lies
Feel free to reach out to me on Twitter @Wailord if I’ve made a mistake above or I can help clarify anything.