Article

SwiftUI’s Missing onLayout Modifier

Paul Himes

March 9, 2021

On a recent SwiftUI project I ran into an issue while implementing a custom translucent title bar over a scroll view. I needed the content of the scroll view to land just below the the title bar and scroll behind it when the scroll view scrolls.

An animation of an iOS UI. At the top is a translucent purple title bar with the text “Title”. Behind and below is a blue scrollable content area with the text “ScrollView Content”. The scrollable content is rubber band bouncing up and down with the content text coming to rest exactly below the title bar.
The Goal

In this article, we’ll walk through how to build this common iOS UI design in SwiftUI. Along the way we’ll also develop a powerful new view modifier which can help us implement this and other complex layouts.



Let’s start with a simple SwiftUI view containing a ScrollView with a TitleBar overlaid on the top edge. Then we’ll add some simple Text content so we can easily see how the views are being laid out.

An animation of an iOS UI. At the top is a translucent purple title bar with the text “Title”. Behind and below is a blue scrollable content area with the text “ScrollView Content”. The scrollable content is rubber band bouncing up and down with the content text coming to rest exactly at the top of the screen.
Initial Layout

Now we need to make sure the scrollable content comes to rest at the bottom of the TitleBar rather than at the top of the screen. While this type of layout is easily accomplished with Auto Layout in UIKit, it’s a bit more difficult in SwiftUI. SwiftUI’s ScrollView type doesn’t offer the same content inset configuration as UIScrollView. Instead, we can simulate the inset by adding a constant padding to the top of the ScrollView’s content.

An animation of an iOS UI. At the top is a translucent purple title bar with the text “Title”. Behind and below is a blue scrollable content area with the text “ScrollView Content”. The scrollable content is rubber band bouncing up and down with the content text coming to rest exactly below the title bar.
Hard-Coded Padding

This looks exactly like what we want! Unfortunately, hard-coding the top padding isn’t going to work because our TitleBar’s height may change at runtime. If the title text, font size, or screen orientation changes, the content could become misaligned with the bottom of the bar.

An animation of an iOS UI. At the top is a translucent purple title bar with the text “Title”. Behind and below is a blue scrollable content area with the text “ScrollView Content”. Size of the text labels is shrinking and growing showing. When the text is small, the scroll view content doesn’t align with the title bar.
Misaligned at Small Font Sizes

We need to communicate the height of the TitleBar to the ScrollView whenever the bar height changes. There isn’t a mechanism in the SwiftUI layout system to connect the properties of sibling views in this way, but there are other ways to pass this information between the two views. One way it can be done is by using a shared @State variable.

The ScrollView’s top padding is now controlled by a property of the view. Next, we need to set that property to the height of the TitleBar. Reading the height of a view at runtime is done with a view called GeometryReader. This is a flexible view which takes whatever layout size its parent view offers and returns it in a closure. Since we don’t want the GeometryReader to interfere with our TitleBar’s layout or view hierarchy, we’ll place it in a background modifier on the TitleBar view. A view in a background modifier always assumes the size of its modified view.

Inside the GeometryReader we need to supply a content view. Again we don’t want anything that will affect the layout or appearance of the view hierarchy, and there are several innocuous views we could choose from. In this case we’ll use a clear Color view.

Now we can use the value of the GeometryProxy provided by the GeometryReader to update the titleBarHeight property of our MainView. To do this, we need a way to run arbitrary code within our view building code. One option is to add an action modifier, such as onAppear, to our Color view. Within the onAppear closure we can set the value of titleBarHeight.

If you run this code, you’ll see that the ScrollView content now perfectly aligns with the bottom of the TitleBar. Unfortunately, the alignment is only correct as long as the size of the TitleBar doesn’t change after appearing. If it does, the ScrollView content and TitleBar will once again be misaligned. This is because the onAppear modifier only updates the titleBarHeight once, when the view appears.

An animation of an iOS UI. At the top is a translucent purple title bar with the text “Title”. Behind and below is a blue scrollable content area with the text “ScrollView Content”. Size of the text labels is shrinking and growing showing. When the text is small, the scroll view content doesn’t align with the title bar.
Still Misaligned When Font Size Changes

At this point we have two options. The first is to add more action modifiers to our Color view. There are actions that fire in most, but not all, situations which might cause the TitleBar height to change. The second option is to abuse SwiftUI’s layout system to run arbitrary code every time the TitleBar’s layout changes. We’re going with option two.

Every time our UI layout changes, SwiftUI requests the body computed property of our views. Normally this property only contains code to build and return a nested hierarchy of views. However, it is possible to include additional statements alongside the view code. For example, we could set the titleBarHeight to the height value from the GeometryProxy directly, outside of any action modifier closure.

This causes two new problems. The first is that the Swift compiler can no longer determine what type of view is contained by our GeometryReader.

The block of code including the GeometryReader. A build error on the first line says, “Unable to infer complex closure return type.”
The Compiler Isn’t Expecting Arbitrary Code

To alleviate this issue, we can extract the content view into a helper function which takes a GeometryProxy parameter and returns a type of some View.

The second problem can be seen when you try to run this code.

The block of code including the layoutWatcher function. A runtime issue on the first line where the title bar height is set says, “Modifying state during view update, this will cause undefined behavior.”
Xcode is Always Watching Out For Us

Xcode generates a runtime issue to notify us that we shouldn’t modify state during a view update. This issue can be silenced by waiting to change the state until after the view update. Here we’ll dispatch the state update asynchronously to the main queue. This will allow the view update to complete before modifying the titleBarHeight value.

This is still slightly risky and could result in continuous, uncontrolled view layout updates. If, for example, changing the titleBarHeight somehow caused the TitleBar view to update, we might end up in an infinite loop. In our case, this isn’t a problem. When the TitleBar height changes, only the ScrollView padding is changed. This does not affect the layout of the TitleBar.

An animation of an iOS UI. At the top is a translucent purple title bar with the text “Title”. Behind and below is a blue scrollable content area with the text “ScrollView Content”. Size of the text labels is shrinking and growing showing. When the font size changes, the scroll view content remains aligned with the title bar.
It Works!

At this point we have a fully functional implementation of a scrollable view with proper padding to avoid a translucent title bar when the view is scrolled all the way to the top. We could stop now, but wouldn’t it be nice to refactor this into a general solution that’s reusable in other situations? Let’s create a new ViewModifier, similar to onAppear, which will let us react to the current size of any view whenever its layout changes.

Most of the code in this ViewModifier comes from our previous implementation. The major difference is, instead of setting the value of titleBarHeight, it takes an action closure which is given a copy of the GeometryProxy for the modified view. It’s also conventional to extend View with a convenience function to apply the ViewModifier. Our function is designed to work similarly to the onAppear modifier. Here’s our final view implementation using our new onLayout ViewModifier.

onLayout is a powerful tool for developing and debugging complex SwiftUI views. Just remember to avoid changing state which would affect the layout of the view you’re observing. I hope you find it useful.

An animation of an iOS UI. At the top is a translucent purple title bar with the text “Title”. Behind and below is a blue scrollable content area with the text “ScrollView Content”. The scrollable content is rubber band bouncing up and down with the content text coming to rest exactly below the title bar.
The Final Product

If you’ve discovered other ways to achieve this effect, I’d love to hear about them in the comments!



Paul works at Livefront , where the test coverage is strong, the designs are good looking, and all the developers are above average.