Article
SwiftUI’s Missing onLayout Modifier
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.
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.
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.
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.
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.
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
.
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.
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
.
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.
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.