The automaticallyAdjustsScrollViewInsets Rabbit Hole

Ryan Fox
10 min readMay 2, 2018

I wrote a few pieces previously on how extended layout worked in iOS 10 and how Apple’s updated the functionality in iOS 11. The gist of it is that Apple wants our scrollable content to go underneath stuff (home indicator, status bars, navigation bars, toolbars) and it accomplishes this by having us constrain our scroll views to go underneath bars, but they’ll set our contentInset so we can still scroll things into view. Simple enough.

Before iOS 11, the method by which Apple did this is through the automaticallyAdjustsScrollViewInsets property on UIViewController, which terrifies me because it:

  • has the terrifying prefix “automatically”
  • is a property on UIViewController

I wanted to use this post to explore — yes, four years after the property was introduced in iOS 7 — what exactly that behavior is. For most people it won’t be of any particular use, as Apple intends for it to “just work”, but it may make for interesting trivia :)

UIKit Documentation

[A value of true] lets container view controllers know that they should adjust the scroll view insets of this view controller’s view to account for screen areas consumed by a status bar, search bar, navigation bar, toolbar, or tab bar. […]

This already feels more ambiguous than it needs to be. A value of true on which view controller informs which view controller that which scroll view insets should be adjusted? Sometimes Apple provides additional knowledge in their headers, like in UIViewController.h:

@property(nonatomic,assign) BOOL automaticallyAdjustsScrollViewInsets API_DEPRECATED(“Use UIScrollView’s contentInsetAdjustmentBehavior instead”, ios(7.0,11.0),tvos(7.0,11.0)); // Defaults to YES

Oh.

Community Documentation

BigNerdRanch:

With iOS 7, UIViewControllers have a property called automaticallyAdjustsScrollViewInsets, and it defaults to YES. This property can make your life much easier, provided you understand how it works.

If you have a scroll view that is either the root view of your view controller (such as with a UITableViewController) or the subview at index 0, then that property will adjust both the contentInset and the scrollIndicatorInsets. This will allow your scroll view to start its content and scroll indicators below the navigation bar (if your view controller is in a navigation controller).

StackOverflow:

iOS grabs the first subview in your ViewController’s view, the one at index 0, and if it’s a subclass of UIScrollView then applies the explained properties to it.

UseYourLoaf:

[…] The fix is to set automaticallyAdjustsScrollViewInsets to false on the container view. Setting it on the table view controller has no effect. […]

One final curiosity. The container view controller only seems to look for a scroll view to manage in the first (index zero) subview hierarchy of its view (so any subview of view.subviews[0]). This means that we also fix the problem by reversing the order or our views (without changing the layout so our table view is still at the top under the navigation bar):

Now it does not matter how you set automaticallyAdjustsScrollViewInsetsyou never get the extra space. Safer though to set it to false to make your intentions clear in case you ever change the order of the views.

So, if we take these at face value (we won’t), we understand that:

  • the property takes effect on our root view (if a UIScrollView) or on our root view’s first subview (if a UIScrollView)
  • according to one source, the property needs to be set on our container view controller

It’s worth pointing out that this is all dug up by the community. Apple doesn’t have this documented, which is a bummer. At least it leads to fun like this, though.

Let’s try it out.

Containment Rules

When building sample applications to test out functionality, it helps to start with the absolute most bare minimum code, then go from there. So, we’ll start with creating a window, then creating a root view controller that only contains a scroll view with a label. This is the minimum amount of view hierarchy we need to cleanly show our behavior as we modify code. I’m going to run on iOS 10.3 to avoid the new iOS 11 UIScrollView.contentInsetAdjustmentBehavior property from clouding our results. We will, however, use the iOS 11/Xcode 9 SDK.

Oh, wait, even though our automaticallyAdjusts property is true, Apple said we need a container view controller. So, let’s instead create a view controller that contains our scrolling view controller:

Well, that didn’t fix anything. Clearly, we have a status bar that should be insetting our scroll view. All of the view controllers have automaticallyAdjusts set to true. What gives? Interestingly, the aforementioned contentInsetAdjustmentBehavior has a relevant tidbit in UIScrollView.h:

UIScrollViewContentInsetAdjustmentAutomatic, // Similar to .scrollableAxes, but for backward compatibility will also adjust the top & bottom contentInset when the scroll view is owned by a view controller with automaticallyAdjustsScrollViewInsets = YES inside a navigation controller, regardless of whether the scroll view is scrollable

The default behavior on iOS 11, “for backward compatibility”, will apply the behavior “when the scroll view is owned by a view controller with automaticallyAdjustsScrollViewInsets = YES inside a navigation controller”.

Wait, what? Are you telling me…

Oh. Replacing our container view controller with a navigation controller (its navigation bar is hidden) now forces our contentInset to account for the status bar. I tried with a UITabBarController as well to see if “container view controller” meant “Apple container view controllers”, but nope, my content ran into the status bar.

What if my navigation controller is a child view controller of the window’s root view controller as opposed to the window’s rootViewController?

So, it seems like as long as a relevant scroll view is inside of a navigation controller, it’ll receive appropriate top insets. What about UITabBarControllers? To test this, I added another label to my scroll view, 1200 points below the top label to force some scrolling behavior.

Well, that’s not what I wanted.

Let’s apply our UINavigationController. Despite using a container view controller, one that Apple provided, we didn’t get the automatic scroll view insetting. What if our tab bar controller had a navigation controller with our ScrollViewController instead?

Would you look at that.

Based on our findings so far, coupled with Apple’s cryptic iOS 11 documentation, I think we can say, for the most part, that automaticallyAdjustsScrollViewInsets will only apply to views that are inside of UINavigationControllers, regardless of the reason it needs to inset (tab bar or status bar).

Hierarchy Rules

Earlier, we said that if a “relevant scroll view” (later edit: digging through the disassembled code, Apple prefers to call this a “content scroll view”. the more you know!) was inside of a navigation controller then it received the content insetting. What is a “relevant scroll view”? The sources we linked above came to a consensus that you count as a relative scroll view if you’re the view controller’s root view or if you’re the root view’s first subview (and, of course, you’re a UIScrollView).

Let’s quickly test the first-subview rule by adding a UIView before we add our UIScrollView. We won’t constrain it anywhere — we’ll just force our scroll view to be something other than our first subview.

Validated. When our scroll view is the first subview of our root view, we’re inset. If it’s the second subview, it is not. For what it’s worth, I tested it with a scroll view as the root view as well, and it was properly inset. So if vc.view or vc.view.subviews[0] is a UIScrollView, we get the inset magic. What if… both are scroll views?

Our root view received the content inset, but its first subview, another UIScrollView, did not… but can we go deeper? Let’s get rid of that second scroll view. What happens if vc.view.subviews[0].subviews[0] is our first UIScrollView?

Neat.

That worked, too. Let’s go a bit deeper. What if vc.view.subviews[0].subviews[0].subviews[0].subviews[0].subviews[0].subviews[0].subviews[0].subviews[0].subviews[0].subviews[0] is our first UIScrollView?

Let’s go ahead and say the rule is the first UIScrollView that is its parent’s first subview and whose ancestors are all their parent’s first subview will be affected.

Property Setting Rules

I complained earlier that Apple seemed a bit ambiguous about where we should be setting automaticallyAdjustsScrollViewInsets. Let’s set up a scenario with a UINavigationController ScrollViewController, the name for the class I’m using whose root view is a UIScrollView. We already know that this works. What if add a container view controller in the middle?

Still works. This means that being inside another view controller doesn’t prevent the content inset magic. Let’s now play with the property. Remember that Apple says:

[A value of true] lets container view controllers know that they should adjust the scroll view insets of this view controller’s view

So, our ScrollViewController should let its ContainerViewController know it needs inset, so we’ll set the property to false on our UINavigationController and ContainerViewController to remove noise:

Nope.

Apparently it’s not the ScrollViewController that needs it. Let’s turn it on the container:

Voilà!

We’re back to getting our inset. This must mean that it doesn’t matter what you set on the navigation controller itself. But we still can’t be entirely certain where it does matter. What if we take out the ContainerVC and just host a ScrollViewController inside of a UINavigationController? Testing again showed that it doesn’t matter what you set on the navigation controller itself — but it also showed that ScrollViewController’s property was what decided.

What if we have ten ContainerViewControllers, all of which have automaticallyAdjustsScrollViewInsets set to false — except for the very first one, the one actually hosted by the navigation controller?

That’s some nice inset you got there, bud.

So, we can add another rule: automaticallyAdjustsScrollViewInsets is only respected on the view controller that is part of a navigation controller’s viewControllers array.

Positioning Rules

We know now that UINavigationControllers are the magic pieces that enable all of this content insetting to work — if we’re inside one, we’ll get inset if we match the rest of the rules. But for things like the status bar, how does it work if we have multiple navigation controllers?

They both get it — sometimes. From what I can tell, the scroll view’s absolute y-position has to be above or equal to the top of the screen to receive insetting . If you offset it by 1 point below the top of the screen, you no longer get inset, but if you constrain the scroll view above the top of the screen, you’re inset properly. It doesn’t seem to matter where you constrain — just where the view is. It also does not matter if something else is consuming the status bar. If we meet the established criteria, we’re going to be inset.

Putting it all together

Let’s re-evaluate the rules we’ve picked up along the way:

  • automaticallyAdjustsScrollViewInsets will only apply to views that are inside of UINavigationControllers
  • the first UIScrollView that is its parent’s first subview and whose ancestors are all their parent’s first subview will be affected
  • automaticallyAdjustsScrollViewInsets is only respected on the view controller that is part of a navigation controller’s viewControllers array
  • the scroll view’s absolute y-position has to be above or equal to the top of the screen to receive insetting

If we wanted to put this in code (and assumed everything was laid out), maybe it would look like this:

It works for the basic cases, including a few of the weird cases above, so I think we’re reasonably close. I’m sure there are plenty of edge cases that cause the above to blow up, and it doesn’t handle UITTabBarController insets. It does stand, I think, as a decent example of how the property works.

In Summary

I’ll just reiterate the points above:

  • automaticallyAdjustsScrollViewInsets will only apply to views that are inside of UINavigationControllers
  • the first UIScrollView that is its parent’s first subview and whose ancestors are all their parent’s first subview will be affected
  • automaticallyAdjustsScrollViewInsets is only respected on the view controller that is part of a navigation controller’s viewControllers array
  • the scroll view’s absolute y-position has to be above or equal to the top of the screen to receive insetting

There’s enough detail above and some sample code to mess with if you want to. I’m also not sure what the deal is with the iOS 11 implementations. I may do another version of this post running the same tests. I imagine the behavior is the same using contentInsetAdjustmentBehavior, at least based on Apple’s documentation.*

Please hit me up @Wailord on Twitter if you see any holes here; this property has become my white whale, at least for the time being, so I’d love to hear if there are issues.

*Later edit: based on testing a few of the cases above, the behavior appears to be the same on iOS 11 with the new property, though setting automaticallyAdjusts=false on the UINC’s child VC overrides the scroll view property, so it’s still respected. It seems that the property is still used as the starting point when UINavigationControllers query their direct child view controllers, but the new scroll view property is what is inspected on the “relevant scroll view”.

--

--

Ryan Fox

iOS developer @Facebook — the opinions expressed here are mine and do not necessarily represent those of my employer. https://www.linkedin.com/in/wailord