Building a stretchy header view with SwiftUI on iOS 18

Published on: June 11, 2024

In iOS 18, SwiftUI's ScrollView has gotten lots of love. We have several new features for ScrollView that give tons of control to us as developers. One of my favorite interactions with scroll views is when I can drag on a list an a header image animates along with it.

In UIKit we'd implement a UIScrollViewDelegate and read the content offset on scroll. In SwiftUI we could achieve the stretchy header effect with GeometryReader but that's never felt like a nice solution.

In iOS 18, it's possible to achieve a stretchy header with little to no workarounds by using the onScrollGeometryChange view modifier.

To implement this stretchy header I'm using the following set up:

struct StretchingHeaderView: View {
    @State private var offset: CGFloat = 0

    var body: some View {
        ZStack(alignment: .top) {
            Image(.photo)
                .resizable()
                .aspectRatio(contentMode: .fill)
                .frame(height: 300 + max(0, -offset))
                .clipped()
                .transformEffect(.init(translationX: 0, y: -(max(0, offset))))

            ScrollView {
                Rectangle()
                    .fill(Color.clear)
                    .frame(height: 300)

                Text("\(offset)")

                LazyVStack(alignment: .leading) {
                    ForEach(0..<100, id: \.self) { item in
                        Text("Item at \(item)")
                    }
                }
            }
            .onScrollGeometryChange(for: CGFloat.self, of: { geo in
                return geo.contentOffset.y + geo.contentInsets.top
            }, action: { new, old in
                offset = new
            })
        }
    }
}

We have an @State private var to keep track of the ScrollView's current content offset. I'm using a ZStack to layer the Image below the ScrollView. I've noticed that adding the Image to the ScrollView results in a pretty stuttery animation probably because we have elements changing size while the scroll view scrolls. Instead, we add a clear Rectangle to the ScrollView to push or content down by an appropriate amount.

To make our effect work, we need to increase the image's height by -offset so that the image increase when our scroll is negative. To prevent resizing the image when we're scrolling down in the list, we use the max operator.

.frame(height: 300 + max(0, -offset))

Next, we also need to offset the image when the user scrolls down in the list. Here's what makes that work:

.transformEffect(.init(translationX: 0, y: -(max(0, offset))))

When the offset is positive the user is scrolling downwards. We want to push our image up what that happens. When the offset is negative, we want to use 0 instead so we again use the max operator to make sure we don't offset our image in the wrong direction.

To make it all work, we need to apply the following view modifier to the scroll view:

.onScrollGeometryChange(for: CGFloat.self, of: { geo in
    return geo.contentOffset.y + geo.contentInsets.top
}, action: { new, old in
    offset = new
})

The onScrollGeometryChange view modifier allows us to specify which type of value we intend to calculate based on its geometry. In this case, we're calculating a CGFloat. This value can be whatever you want and should match the return type from the of closure that you pass next.

In our case, we need to take the scroll view's content offset on the y axis and increment that by the content inset's top. By doing this, we calculate the appropriate "zero" point for our effect.

The second closure is the action that we want to take. We'll receive the previous and the newly calculated value. For this effect, we want to set our offset variable to be the newly calculated scroll offset.

All this together creates a fun strechy and bouncy effect that's super responsive to the user's touch!

Categories

SwiftUI

Subscribe to my newsletter