iosswiftuiscrollviewlazyvstack

Multiple pinned views at the top of a LazyVStack within a ScrollView


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.


Solution

  • 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:

    Here 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)
            }
        }
    }
    

    Animation


    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
    }
    

    Animation


    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)