Search code examples
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:

    • The ids of the items in the ScrollView need to be unique.
    • As you can see, the updated example is using hard-coded ids for the position to scroll to. You will probably want to determine the ids in a more dynamic way. You might also want to check the current scroll position first and only change it if necessary.
    • Instead of using .scrollPosition, you could also set the position using a ScrollViewReader and .scrollTo. This works too. However, it doesn't give you the option of checking the current position before setting the new position.
    • I first tried adding a didSet setter observer to the state variable tab, but setting the scroll position this way didn't work, possibly because it is called before the scroll view has adjusted. Using .onChange works better.

    Animation