I am trying to create a ScrollView with a LazyVStack where two headers are pinned at the top. It should also work within a NavigationStack using large display mode for the title. Both headers should be inside the ScrollView such that the animation for the large title does work when scrolling (going from the large title to the small one when scrolling down).
This is what I tried:
struct StickyHeaderHeightKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
struct ContentView: View {
let scrollCoordinateSpace = "scroll"
@State private var headerHeight: CGFloat = 0
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
GeometryReader { geometry in
let frame = geometry.frame(in: .named(scrollCoordinateSpace))
let offset = max(0, -frame.minY)
HStack {
Image(systemName: "pin.fill")
Text("Sticky Header")
.font(.headline)
Spacer()
}
.padding()
.background(Color.red.opacity(0.8))
.offset(y: offset)
.background(
GeometryReader { proxy in
Color.clear
.preference(key: StickyHeaderHeightKey.self, value: proxy.size.height)
}
)
}
.frame(height: headerHeight)
.zIndex(1)
// --- Main Content ---
LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) {
ForEach(0..<50) { index in
Section {
VStack(alignment: .leading, spacing: 0) {
Text("Content \(index + 1)")
.padding(.vertical, 10)
.padding(.horizontal)
Text("Content \(index + 1)")
.padding(.vertical, 10)
.padding(.horizontal)
Text("Content \(index + 1)")
.padding(.vertical, 10)
.padding(.horizontal)
}
.background(Color.green.opacity(0.5))
} header: {
VStack(spacing: 0) {
Text("Header")
.padding(.vertical, 6)
.padding(.horizontal)
.background(Color.blue.opacity(0.5))
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.blue.opacity(0.5))
}
}
}
}
}
.coordinateSpace(name: scrollCoordinateSpace)
.navigationTitle("Large Title")
.navigationBarTitleDisplayMode(.large)
.onPreferenceChange(StickyHeaderHeightKey.self) { value in
headerHeight = value
}
}
}
}
The problem I am facing is that the headers from the LazyVStack Section are always pinned at the top most position of the ScrollView and not below the red sticky header.
One way to solve might be to show the top (red) header as a safeAreaInset
to the ScrollView
.
Quite a lot of the content that you had before can be dropped. In particular, none of the following are needed:
PreferenceKey
VStack
GeometryReader
that was measuring the height of the headerHere is the (complete) updated example to show it working:
struct ContentView: View {
var body: some View {
NavigationStack {
ScrollView {
// --- Main Content ---
LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) {
ForEach(0..<50) { index in
Section {
VStack(alignment: .leading, spacing: 0) {
Text("Content \(index + 1)")
.padding(.vertical, 10)
.padding(.horizontal)
Text("Content \(index + 1)")
.padding(.vertical, 10)
.padding(.horizontal)
Text("Content \(index + 1)")
.padding(.vertical, 10)
.padding(.horizontal)
}
.background(Color.green.opacity(0.5))
} header: {
Text("Header")
.padding(.vertical, 6)
.padding(.horizontal)
.background(Color.blue.opacity(0.5))
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.blue.opacity(0.5))
}
}
}
}
.safeAreaInset(edge: .top, spacing: 0) {
HStack {
Image(systemName: "pin.fill")
Text("Sticky Header")
.font(.headline)
Spacer()
}
.padding()
.background { Color.red.opacity(0.8) }
}
.navigationTitle("Large Title")
.navigationBarTitleDisplayMode(.large)
}
}
}
EDIT In a comment you said:
The problem I see with this solution is that you can see when scrolling to the top the red sticky header is not scrolling down when the title is. It overlaps the title.
I assume you are referring to when the scrolled content is pulled down lower than it can actually scroll. When released, it bounces back to its starting position. While this is happening, the header stays stationary. However, the navigation title moves with the content, so when the content is pulled down, the navigation title goes over the header. The same can also happen with an intertia scroll, as can be seen in the gif above.
One way to keep the header with the content when it is pulled down is to measure the scroll offset and apply this as a y-offset to the header, if it is negative.
Since you are targeting iOS 18, you can use .onScrollGeometryChange
to measure the scrolled offset and save this to a state variable:
@State private var scrollOffset = CGFloat.zero
ScrollView {
// ... content as before
}
.safeAreaInset(edge: .top, spacing: 0) {
HStack {
Image(systemName: "pin.fill")
Text("Sticky Header")
.font(.headline)
Spacer()
}
.padding()
.background { Color.red.opacity(0.8) }
.offset(y: max(0, -scrollOffset)) // 👈 added
}
.navigationTitle("Large Title")
.navigationBarTitleDisplayMode(.large)
.onScrollGeometryChange(for: CGFloat.self) { proxy in // 👈 added
proxy.contentOffset.y + proxy.contentInsets.top
} action: { _, newOffset in
scrollOffset = newOffset
}
EDIT2 Instead of using .onScrollGeometryChange
to measure the scroll offset, you could also use .onGeometryChange
to measure the position of the LazyVStack
in the coordinate space of the ScrollView
. It works the same:
ScrollView {
// --- Main Content ---
LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) {
// ... as before
}
.onGeometryChange(for: CGFloat.self) { proxy in // 👈 here
proxy.frame(in: .scrollView).minY
} action: { minY in
scrollOffset = -minY
}
}
.safeAreaInset(edge: .top, spacing: 0) {
// ... as before
}
.navigationTitle("Large Title")
.navigationBarTitleDisplayMode(.large)