The Particulars of the Safe Area and contentInsetAdjustmentBehavior in iOS 11

Ryan Fox
Ryan Fox
May 4, 2018 · 15 min read

Introduction

I’m a bit late to the game (safe areas were introduced at WWDC 2017, yet WWDC 2018 is 31 days (!) from today), but I feel that “deeper dives” into frequently used UIKit APIs are timeless pieces, even after they’re deprecated. Developers often have to deal with legacy APIs long after Apple deprecates them, and even if that’s not the case, knowing how things work past the basics helps understand newer APIs and helps clarify how Apple intends for things to work.

Note that I won’t spend too much time talking about the basics, as they’re pretty established. I’d more like to explore the what-ifs, as I find once you can start predicting the behavior of perceived “edge cases”, you’ve gained a much deeper understanding of the implementations provided by UIKit today. My goal with this article is to get everyone up to speed just in time for WWDC, where we can invalidate it all and start from scratch. 😉

My previous posts were mostly focused on iOS 10 with callouts to iOS 11, but this post will be completed focused on iOS 11, and as such, all simulator screenshots are from simulated iOS 11 devices. I won’t actually use the iPhone X too much, as I think we can learn more from learning what the safe area is as a concept before thinking of it as “the notch” or “the home indicator”.


A Quick Recap of Safe Area in iOS 11

The safe area, introduced in iOS 11, is pretty straightforward, so I won’t spend too long going over the basics. You can see Apple talk about it a bit here, you can see Apple use it here, and you can see more here. Conceptually, the safe area represents the area in which your content can safely exist. Like the distinction between a view’s top and a view controller’s topLayoutGuide previously, the difference between a view’s top and its safeAreaLayoutGuide’s top is handled differently for typical views and for scroll views.

Within Apple’s container view controllers (like UITabBarController and UINavigationController), your views are pinned to the top/bottom of the container’s view while the content starts (well, it should) below/above the safeAreaLayoutGuide’s top/bottom. Scroll views treat this the same way conceptually by setting a contentInset to account for the obscuring content so views can scroll underneath them. This is all the same in concept as extended layout and top/bottomLayoutGuides; the only difference is now there’s more stuff to obscure views and we need to account for additional sides.

Like many things in the world of software development, this is very straightforward conceptually, but in practice, there are a lot of additional things to consider. Just looking at Apple’s documentation raises a few questions (although I’ve covered some of it previously):

I think if you can confidently answer the above, you’ve got a pretty decent grasp on how modern layout works on iOS. Let’s jump into the above questions and solidify what we know — and maybe learn a thing or two on the way!


safeAreaInsets in iOS 11

As per usual, let’s start with the UIKit documentation for this new UIView (not just UIScrollView) property:

The safe area of a view reflects the area not covered by navigation bars, tab bars, toolbars, and other ancestors that obscure a view controller’s view. […] You obtain the safe area for a view by applying the insets in this property to the view’s bounds rectangle. If the view is not currently installed in a view hierarchy, or is not yet visible onscreen, the edge insets in this property are 0.

For the view controller’s root view, the insets account for the status bar, other visible bars, and any additional insets that you specified using the additionalSafeAreaInsets property of your view controller. For other views in the view hierarchy, the insets reflect only the portion of the view that is covered. For example, if a view is entirely within the safe area of its superview, the edge insets in this property are 0.

This is actually pretty good documentation, though I do have a few questions. Let’s start playing around with some view hierarchies and investigate what safeAreaInsets we have.

So far, so good. This is the behavior we should all expect. What if we add a container view wrapping our view? Will it still be 64? The documentation implies that it should, as there will still be 64 points of our view covered, and our container view should have the same insets.

We do indeed see a safeAreaInsets.top value of 64 on our innermost view, but what about the container? To show off some niceties in Xcode/UIKit, I made a view subclass and overrode its debugDescription to print out the safeAreaInsets so I could investigate more easily later. The container view has this to say:

Cool. Again, we’re not learning anything new here. safeAreaInsets, as the documentation states, always refers to how much of a particular view is obscured. Moving our inner view down by 10 points accordingly shows safeAreaInsets.top of the container as 64 and a value of 54 on the inner view. What about on the iPhone X?

All of that looks good (although we should note that the left and right insets are the same despite only one of them having obscured content). Let’s start playing with additionalSafeAreaInsets, a property on UIViewController. This is what the documentation has to say about it:

Use this property to adjust the safe area insets of this view controller’s views by the specified amount. The safe area defines the portion of your view controller’s visible area that is guaranteed to be unobscured by the system status bar or by an ancestor-provided view such as the navigation bar.

You might use this property to extend the safe area to include custom content in your interface. For example, a drawing app might use this property to avoid displaying content underneath tool palettes.

Let’s go back to the simple, single-view-controller set-up, but let’s set additionalSafeAreaInsets on the controller to {1, 0, 0, 0}.

Our safe area insets were changed, but visually, nothing did. And why should it? Our safe area insets have impacted our safeAreaLayoutGuide, but we haven’t constrained anything to it. Let’s revert that change, change our ContainerView to constrain views to its safe area, then set additionalSafeAreaInsets = {20, 20, 20, 20} on the container. Can you predict the safeAreaInsets for both the container and the inner view?

Our container has no safeAreaInset by default as there’s no content covering it, but we’ve set an additionalSafeAreaInsets value of {20, 20, 20, 20}. This causes the container’s safeAreaInsets to read as follows:

Just like the documentation says, safeAreaInsets accounts for everything that a given view is accounting for. Our inner view is constrained to the safeAreaLayoutGuide of its parent, so as far as it’s concerned, nothing is obscured. What if we constrain back to the edges of our superview? How will that affect our view?

Our container view still has safeAreaInsets of {20, 20, 20, 20}, but our subview isn’t constrained to the safe area of the container. As a result, our inner view knows that each edge of its view is running into 20 points that its parent view says are not “safe”. Let’s throw a navigation bar into the mix:

The navigation bar covers 44 points of our content and our superview (allegedly) covers 20 points of our content, so everything still makes sense. In the real world, we’d want to set additionalSafeAreaInsets on one of our children to tell it just how much of its content will be obscured by non-system-default content so it can adjust its own content to account for it. We won’t worry about how UIKit engineers implemented this right now (though it’s a good topic for another day) — we can just trust that it works and live by these two axioms:

  • a given view’s safeAreaInsets property represents how much of that view is obscured by content from superviews/containers/the status bar
  • a given view controller’s additionalSafeAreaInsets is a property we can set to say how much of that view controller’s view is covered by other content

None of this is groundbreaking, but I find that trying a few use cases of an API can help make it clear what exactly is going on. I should also note that UIView has gained a safeAreaInsetsDidChange() method while UIViewController gained viewSafeAreaInsetsDidChange().

adjustedContentInset in iOS 11

Recall how translucency works in iOS when it comes to laying out views (maybe you’ve forgotten since you read the introduction 😅). Views that are laying out under obscuring bars will be pinned to the top of the obscuring views, but the view controller that owns the obscured view will have its top/bottomLayoutGuide adjusted to be right below/above the bars. Then, our view can lay out behind the translucent bars, and our content can still live in the visible area. Neat!

Scroll views, however, receive a contentInset to account for the space between the layout guide and anchor of a given direction, which allows us to scroll our content under translucent bars, but still scroll the content entirely into view. When UIKit performed its magic, it set our scroll view’s contentInset property, which may or may not have conflicted with any inset we set. In iOS 11, Apple’s split apart those two properties: anything Apple sets (i.e. safe area adjustments) will be added to our contentInset (which we can now think of as “developer-assigned-content-Inset”), and the sum of those two values will be our adjustedContentInset, which is what the old contentInset used to be. As an aside, UIKit now has a method for adjustedContentInsetDidChange() on UIScrollView if you need to respond to the value changing.

To recap:

  • adjustedContentInset is a readonly property equal to our scroll view’s contentInset plus system-defined insets depending on our scroll view behavior
  • We set our own contentInsets, but the system calculates safeAreaInsets (which itself is calculated by summing some user-defined insets with some system-defined insets) and applies them according to our scroll view behavior

Why might we want to do this? After all, if we have more safe area to account for, wouldn’t we just set additionalSafeAreaInsets instead of setting a contentInset? Actually, I can’t come up with a great reason off the top of my head. contentInsets are usually just set to accommodate content being overlaid on top of scroll views, so it seems like we should really just be using adjustedSafeAreaInsets going forward unless you disable the behavior. Speaking of which…

contentInsetAdjustmentBehavior in iOS 11

Let’s just dive right into how safeArea affects scroll views. Apple says of contentInsetAdjustmentBehavior:

This property specifies how the safe area insets are used to modify the content area of the scroll view. The default value of this property is automatic.

We understand what safeAreaInsets are and we understand what Apple prefers scroll views to do. What sort of behavior, then, can we have our scroll views display? Visiting the documentation for UIScrollViewContentInsetAdjustmentBehavior doesn’t show much but we can glean what we need from UIScrollView.h:

We can ignore .always. It’ll always add the scroll view’s safeAreaInsets to adjustedContentInset.

Let’s set up a scenario where we have a scroll view and scroll view content that fills the screen. Let’s also add a navigation bar, but also try without one to vet the first claim. We’ll use an adjustment behavior of .automatic:

The documentation again explains everything we see here. In the first picture, we have a status bar and a navigation bar, so our safeAreaInsets.top is 64. automaticallyAdjustsScrollViewInsets defaults to true, so that’s still true. Our ScrollViewController is inside of a UINavigationController. We pass all of Apple’s requirements, and accordingly, they give us free contentInset. It’s worth noting that the documentation differs from the behavior we sussed out with automaticallyAdjustsScrollViewInsets: while Apple says all that it looks for is a scroll view owned by a view controller with the property set to true, at least previously, it actually looked to the view controller in the navigation controller’s viewControllers array for the property, and only applied the value if the scroll view passed the crazy first-subview test. Did that carry over? First, let’s try it with a container view controller owning the scroll view controller so that it’s not the direct child of the navigation controller. Second, let’s make the view controller that owns the scroll view not automatically adjust; then, let’s set automaticallyAdjusts[…] to true and make our scroll view the second view inside of a ContainerView:

And to think I almost made it through the whole post just restating what Apple (and the Internet) has said since all of this was introduced! The documentation accurately stated that behavior would work if our scroll view’s owning view controller had automaticallyAdjusts[…] set to true and that view controller was in a UINavigationController (left). We broke that criteria (middle) by setting the ScrollViewController’s automaticallyAdjusts[…] to false, and we no longer received the insetting. What Apple didn’t mention (and we only know from this rabbit hole) is that this property only works if we pass the “relevantScrollView” logic present in my previous post. While this may be a contrived scenario, I could see someone adding some view to the bottom of the screen, then adding a scroll view and wondering why it’s not receiving that magic goodness. Well, there’s your answer.

This fantastic post by @evgenmikhaylov states that it’ll only apply if the view controller’s view’s first subview is a scroll view (plus previous logic). More accurately, the scroll view behavior will take effect on the scroll view that passes this test from the view controller:

Maybe this is pointless trivia. I thought it was cool! Either that or I’m trying to justify the time I’ve spent investigating it. 😄

Note that nothing about automatic discusses landscape, which means that unless you have horizontally scrollable content, you won’t get inset. We meet earlier criteria for .scrollableAxes (we can scroll down), though, so our bottom is still set up for us.

If we wanted this to look proper, we should have constrained our content to the scroll view’s safeAreaLayoutGuide.left. At this point, that should make sense!

.scrollableAxes isn’t particularly interesting, and the same post I linked has a niftier explanation/gif of it. For the sake of completion:

Our vertical axis is scrollable in both scenarios, so we’re inset. Our horizontal axis is not, so it is. Nice.

The last one I want to cover doesn’t seem particularly interesting at first, but there are a few things worth knowing about .never. The behavior itself is straightforward: don’t do any magic. But if we don’t do any magic, it’s essentially treating our scroll view like any old UIView. What could go wrong? Let’s compare .never with automaticallyAdjusts = false.

These two look identical. Are they? Not quite. We said that setting .never would treat our scroll views like any other views, right? What other implications would that have? Let’s compare our labels in the two scenarios. Can you guess which is our top label using .never and which is our top label using automaticallyAdjustsScrollViewInsets = false?

The first one is using .never, as automaticallyAdjusts is true, so our scroll view is told to abide by its behavior property, but that property then says “act like a normal UIView”. So, it tells the top label it’s obscured by the 44-point iPhone X status bar. Here, this doesn’t have any real problems, but it’s a technical note you should be aware of. The scroll view isn’t relaying out as you scroll, so those safeAreaInsets persist until relayout.

The second line is using automaticallyAdjusts = false, the scroll view (presumably) eats the safeAreaInsets and does not propagate them onto its subviews. Some Googling (Twittering?) shows that this seems to be the case, so we should be mindful of the side effects of setting .never to fix something that should be fixed another way, much like we shouldn’t set our edgesForExtendedLayout to [] simply because it seems to fix what we’re trying to fix.

So, now that we’ve covered it all, what can we takeaway from this property?

  • UIScrollViews have an explicit contentInsetAdjustmentBehavior property that replaces the precarious, now-deprecated UIViewController.automaticallyAdjustsScrollViewInsets
  • We can trust the Apple documentation for the most part when it comes to when adjustment behavior will take effect, but .automatic also has some undocumented behavior: if your scroll view isn’t returned by its view controller running sequence(self.view) { $0.subviews.first }.first { $0 as? UIScrollView }, then you won’t get the noted behavior.
  • Setting our contentInsetAdjustmentBehavior to .never will force our scroll view to behave like a “normal” UIView and propagate safeAreaInsets forward. Typically, the scrollView would consume it.

In Summary

The bullet points:

  • a given view’s safeAreaInsets property represents how much of that view is obscured by content from superviews/containers/the status bar
  • a given view controller’s additionalSafeAreaInsets is a property we can set to say how much of that view controller’s view is covered by other content
  • adjustedContentInset is a readonly property equal to our scroll view’s contentInset plus system-defined insets depending on our scroll view behavior
  • We set our own contentInsets, but the system calculates safeAreaInsets (which itself is calculated by summing some user-defined insets with some system-defined insets) and applies them according to our scroll view behavior
  • UIScrollViews have an explicit contentInsetAdjustmentBehavior property that replaces the precarious, now-deprecated UIViewController.automaticallyAdjustsScrollViewInsets
  • We can trust the Apple documentation for the most part when it comes to when adjustment behavior will take effect, but .automatic also has some undocumented behavior: if your scroll view isn’t returned by its view controller running sequence(self.view) { $0.subviews.first }.first { $0 as? UIScrollView }, then you won’t get the noted behavior.
  • Setting our contentInsetAdjustmentBehavior to .never will force our scroll view to behave like a “normal” UIView and propagate safeAreaInsets forward. Typically, the scrollView would consume it.

Having now pored through automaticallyAdjustsScrollViewInsets for who knows how many hours and now done the same for the iOS 11 replacement, I think we can say we have a decent idea of how this all works. We at least have enough to start debugging and figure out where along the way our logic is wrong if things aren’t displaying quite right.

If you’ve any comments or questions, please feel free to reach out to me on Twitter @Wailord. I’ll try to identify something else that we can go over in the near future. Until then!

Did I mention it’s only 31 days until WWDC? 🎉

Ryan Fox

Written by

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

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade