swiftuilazyvstack

Double sticky header in SwiftUI


Here's a full code example of an attempt at a double sticky header view:

struct ContentView: View {
    var body: some View {
        ScrollView(showsIndicators: false) {
            LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) {
                Section {
                    SecondView()
                } header: {
                    HeaderView()
                }
            }
        }
    }
}

struct HeaderView: View {
    var body: some View {
        Rectangle()
            .fill(.green)
            .frame(height: 50)
    }
}

struct SecondView: View {
    var body: some View {
        LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) {
            Section {
                VStack {
                    ForEach(0..<20) { index in
                        ItemView(index: index)
                    }
                }
            } header: {
                SecondHeaderView()
            }
        }
    }
}

struct SecondHeaderView: View {
    var body: some View {
        Rectangle()
            .fill(.red)
            .frame(height: 60)
    }
}

struct ItemView: View {
    let index: Int
    var body: some View {
        VStack {
            Text("Item \(index)")
                .padding()
        }
        .frame(height: 80)
        .background(.gray)
    }
}

#Preview {
    ContentView()
}

Note that initially, the red SecondHeaderView is correctly positioned below the green HeaderView. However, when scrolling, the red header scrolls beneath the green header until it sticks at the same y position as the green header.

I need the red header to stay anchored below the green header. Note that SecondView may not always be embedded in a ContentView. It could live by itself and still need its own single sticky header to work, so I think both views need the LazyVStack.

I've messed with various GeometryReader view offsets, trying to get the red header to position correctly but I can't get it right.


Solution

  • Here are two possible ways to solve

    1. Combine the headers together

    An easy way to solve is to combine the two headers together. If it is possible that SecondView may be shown in isolation then you can pass a flag, to indicate whether the header should be shown or not:

    // ContentView
    
    ScrollView(showsIndicators: false) {
        LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) {
            Section {
                SecondView()
            } header: {
                VStack(spacing: 0) {
                    HeaderView()
                    SecondHeaderView()
                }
            }
        }
    }
    
    struct SecondView: View {
        var showHeader = false
        var body: some View {
            LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) {
                Section {
                    // ...
                } header: {
                    if showHeader {
                        SecondHeaderView()
                    }
                }
            }
        }
    }
    

    To show SecondView in isolation:

    // ContentView
    
    ScrollView(showsIndicators: false) {
        SecondView(showHeader: true)
    }
    

    2. Apply padding to the second header

    Alternatively, if it can be assumed that the second header will be in the correct position when initially shown then this position can be measured in .onAppear. Then, to keep it in the same position, top padding can be applied to compensate for any scroll movement.

    The same padding must be applied as negative top padding to the scrolled content, otherwise the content doesn't move until the drag movement reaches the height of the first header.

    I found that the header was making tiny movements when scrolling was happening, which was causing errors in the console about "action tried to update multiple times per frame". These errors can be prevented by checking that the adjustment differs from the previous amount by a threshold amount, 0.1 works fine.

    struct SecondView: View {
        @State private var initialOffset = CGFloat.zero
        @State private var topPadding = CGFloat.zero
    
        var body: some View {
            LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) {
                Section {
                    VStack {
                        // ...
                    }
                    .padding(.top, -topPadding)
                } header: {
                    SecondHeaderView()
                        .padding(.top, topPadding)
                        .background {
                            GeometryReader { proxy in
                                let minY = proxy.frame(in: .scrollView).minY
                                Color.clear
                                    .onAppear {
                                        initialOffset = minY
                                    }
                                    .onChange(of: minY) { oldVal, newVal in
                                        let adjustment = max(0, initialOffset - newVal)
                                        if abs(topPadding - adjustment) > 0.1 {
                                            topPadding = adjustment
                                        }
                                    }
                            }
                        }
                }
            }
        }
    }
    

    With this approach, SecondView can be shown in isolation without needing to pass a flag.


    If it can't be assumed that the second header will be in the correct position at initial show then the height of the parent header can be passed as a parameter instead. This is perhaps a safer way of solving when using the padding approach:

    struct ContentView: View {
        @State private var headerHeight = CGFloat.zero
    
        var body: some View {
            ScrollView(showsIndicators: false) {
                LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) {
                    Section {
                        SecondView(parentHeaderHeight: headerHeight)
                    } header: {
                        HeaderView()
                            .onGeometryChange(for: CGFloat.self) { proxy in
                                proxy.size.height
                            } action: { height in
                                headerHeight = height
                            }
                    }
                }
            }
        }
    }
    
    struct SecondView: View {
        var parentHeaderHeight = CGFloat.zero
        @State private var topPadding = CGFloat.zero
    
        var body: some View {
            LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) {
                Section {
                    VStack {
                        // ...
                    }
                    .padding(.top, -topPadding)
                } header: {
                    SecondHeaderView()
                        .padding(.top, topPadding)
                        .onGeometryChange(for: CGFloat.self) { proxy in
                            proxy.frame(in: .scrollView).minY
                        } action: { minY in
                            let adjustment = max(0, parentHeaderHeight - minY)
                            if abs(topPadding - adjustment) > 0.1 {
                                topPadding = adjustment
                            }
                        }
                }
            }
        }
    }
    

    Both approaches work the same. This is how it looks for the case of the double header:

    Animation