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.
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()
}
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:
ScrollView
need to be unique..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.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.