iosswiftuiscrollviewswiftui-listlazyvgrid

(SwiftUI) Segmented Picker in a pinned SectionHeader does not Update ScrollView ContentHeight as expected


Within a Scrollview, i am trying to have a pinned SectionHeader with a simple segmented PickerView, that can switch to two different item Stacks. One very long and one very short. This works fine, as long as the SectionHeader is not sticky. As soon as it does become sticky, the problems start:

As you can see in the video, if i scroll the long left side (while sticky) and then switch to the short right side, the stickyHeader loses its anchored position. Is there any way to prevent this from happening?I tried several things already without any success. (For example, a GeometryReaders proxy that scrolls manually to the top as soon as i switch the tap)

From my Understanding, the problem lies within the ScrollViews ContentHeight, which doesn't get updated correctly. This is very much visible, as the ScrollViewIndicator does not get a visual update in his length also.

Is this possible to achieve, or is the PickerView not made to work within a List of multiple Sections at all? Is there any way to update the ScrollView ContentHeight in a way, that the stickyHeader keeps its position?

Any Hint is much appreciated!

I've also added a video and the source code for reference.

Issue with Sticky Picker and ScrollView Update When Switching Lists

struct ContentView: View {
    @State private var tab = 0

    var body: some View {
        VStack(spacing: 0) {
            let cols = [GridItem(.flexible())]
            
            ScrollView {
                LazyVGrid(columns: cols, pinnedViews: [.sectionHeaders]) {
                    
                    Section {
                        ForEach(1...2, id: \.self) { count in
                            Text("Section 1 Item \(count)")
                                .frame(maxWidth: .infinity, alignment: .leading)
                              .padding().border(Color.blue)
                        }
                    } header: {
                        Text("Section 1")
                            .frame(maxWidth: .infinity, minHeight: 50, alignment: .center)
                            .background(Color.blue)
                    }

                    Section {
                        if tab == 0 {
                            ForEach(10...20, id: \.self) { count in
                                Text("Section 2 Tab 0 Item \(count)")
                                  .frame(maxWidth: .infinity, alignment: .leading)
                                  .padding().border(Color.purple)
                            }
                        }
                        
                        if tab == 1 {
                            ForEach(3...5, id: \.self) { count in
                                Text("Section 2 Tab 1 Item \(count)")
                                    .frame(maxWidth: .infinity, alignment: .leading)
                                  .padding().border(Color.purple)
                            }
                        }

                    } header: {
                        Picker("", selection: $tab) {
                            Text("Long").tag(0)
                            Text("Short").tag(1)
                        }
                        .pickerStyle(.segmented).padding().background(Color.purple)
                    }
                }

                LazyVGrid(columns: cols) {
                        Section {
                            ForEach(30...50, id: \.self) { count in
                                Text("Section 3 Item \(count)")
                                    .frame(maxWidth: .infinity, alignment: .leading)
                                    .padding().border(Color.green)
                            }
                        } header: {
                            Text("Section 3")
                                .frame(maxWidth: .infinity, minHeight: 50, alignment: .center)
                                .background(Color.green)
                            
                        }
                }
            }
        }
        .font(.caption)
    }
}

#Preview {
    ContentView()
}

Solution

  • You could try setting the scroll position whenever the tab is switched.

    One way to do this is to use .scrollPosition on the ScrollView, together with .scrollTargetLayout on the containers inside the ScrollView. Then add an .onChange callback to detect a change of tab and update the scroll position when a change happens. This brings the header with the picker back again, if it was off-screen after the tab change.

    @State private var scrollPosition: Int?
    
    ScrollView {
        LazyVGrid(columns: cols, pinnedViews: [.sectionHeaders]) {
            // ...
        }
        .scrollTargetLayout()
    
        LazyVGrid(columns: cols) {
            // ...
        }
        .scrollTargetLayout()
    }
    .scrollPosition(id: $scrollPosition)
    .onChange(of: tab) { oldVal, newVal in
        withAnimation {
            scrollPosition = newVal == 0 ? 10 : 3
        }
    }
    

    Notes:

    Animation


    EDIT Following up on your comment: if I understand correctly, you want the first row of section 2 to appear immediately below the purple header when the tab selection is changed, instead of appearing under the purple header, as sometimes happens at the moment.

    I think you were on the right track by setting an anchor for the scroll position.

    These changes are needed for it to work this way:

    1. Add state variables for the 2 heights to be measured:
    @State private var scrollHeight = CGFloat.zero
    @State private var stickyHeaderHeight = CGFloat.zero
    
    1. Add .onGeometryChange modifiers to the sticky header and the ScrollView:
    Picker("", selection: $tab) {
        // ...
    }
    // ... other modifiers
    .onGeometryChange(for: CGFloat.self) { proxy in
        proxy.size.height
    } action: { height in
        stickyHeaderHeight = height
    }
    
    ScrollView {
        // ...
    }
    // ... other modifiers
    .onGeometryChange(for: CGFloat.self) { proxy in
        proxy.size.height
    } action: { height in
        scrollHeight = height
    }
    

    Alternatively, you could define a view extension to perform this measurement, see this answer for how this can be done (it was my answer).

    1. Define the spacing for the LazyVGrid:
    let spacing: CGFloat = 10
    
    LazyVGrid(columns: cols, spacing: spacing, pinnedViews: [.sectionHeaders]) {
        // ...
    }
    
    1. Add a computed property for the scroll anchor:
    private var anchor: UnitPoint {
        scrollHeight > 0 && stickyHeaderHeight > 0
            ? UnitPoint(x: 0.5, y: (stickyHeaderHeight + spacing) / scrollHeight)
            : .top
    }
    
    1. Supply the anchor when setting the .scrollPosition:
    .scrollPosition(id: $scrollPosition, anchor: anchor)
    
    1. Change the .onChange callback, so that it only sets the scroll position when it is actually necessary. This prevents the view from being scrolled up when section 1 is still showing:
    .onChange(of: tab) { oldVal, newVal in
        if (scrollPosition ?? 0) > 2 {
            withAnimation {
                scrollPosition = newVal == 0 ? 10 : 3
            }
        }
    }
    

    Here is the fully updated code with all changes applied:

    struct ContentView: View {
        let spacing: CGFloat = 10
        @State private var tab = 0
        @State private var scrollPosition: Int?
        @State private var scrollHeight = CGFloat.zero
        @State private var stickyHeaderHeight = CGFloat.zero
    
        private var anchor: UnitPoint {
            scrollHeight > 0 && stickyHeaderHeight > 0
                ? UnitPoint(x: 0.5, y: (stickyHeaderHeight + spacing) / scrollHeight)
                : .top
        }
    
        var body: some View {
            VStack(spacing: 0) {
                let cols = [GridItem(.flexible())]
    
                ScrollView {
                    LazyVGrid(columns: cols, spacing: spacing, pinnedViews: [.sectionHeaders]) {
    
                        Section {
                            ForEach(1...2, id: \.self) { count in
                                Text("Section 1 Item \(count)")
                                    .frame(maxWidth: .infinity, alignment: .leading)
                                    .padding()
                                    .border(.blue)
                            }
                        } header: {
                            Text("Section 1")
                                .frame(maxWidth: .infinity, minHeight: 50, alignment: .center)
                                .background(.blue)
                        }
    
                        Section {
                            if tab == 0 {
                                ForEach(10...20, id: \.self) { count in
                                    Text("Section 2 Tab 0 Item \(count)")
                                        .frame(maxWidth: .infinity, alignment: .leading)
                                        .padding()
                                        .border(.purple)
                                }
                            }
    
                            if tab == 1 {
                                ForEach(3...5, id: \.self) { count in
                                    Text("Section 2 Tab 1 Item \(count)")
                                        .frame(maxWidth: .infinity, alignment: .leading)
                                        .padding()
                                        .border(.purple)
                                }
                            }
    
                        } header: {
                            Picker("", selection: $tab) {
                                Text("Long").tag(0)
                                Text("Short").tag(1)
                            }
                            .pickerStyle(.segmented)
                            .padding()
                            .background(.purple)
                            .onGeometryChange(for: CGFloat.self) { proxy in
                                proxy.size.height
                            } action: { height in
                                stickyHeaderHeight = height
                            }
                        }
                    }
                    .scrollTargetLayout()
    
                    LazyVGrid(columns: cols, spacing: spacing) {
                        Section {
                            ForEach(30...50, id: \.self) { count in
                                Text("Section 3 Item \(count)")
                                    .frame(maxWidth: .infinity, alignment: .leading)
                                    .padding()
                                    .border(.green)
                            }
                        } header: {
                            Text("Section 3")
                                .frame(maxWidth: .infinity, minHeight: 50, alignment: .center)
                                .background(.green)
    
                        }
                    }
                    .scrollTargetLayout()
                }
                .scrollPosition(id: $scrollPosition, anchor: anchor)
                .onChange(of: tab) { oldVal, newVal in
                    if (scrollPosition ?? 0) > 2 {
                        withAnimation {
                            scrollPosition = newVal == 0 ? 10 : 3
                        }
                    }
                }
                .onGeometryChange(for: CGFloat.self) { proxy in
                    proxy.size.height
                } action: { height in
                    scrollHeight = height
                }
            }
            .font(.caption)
        }
    }
    

    Animation