Building a stretchy header view with SwiftUI on iOS 18
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!