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.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.
The anchor can be specified as a UnitPoint
in which the y
component represents the fraction of the scroll height to use as the position.
The fraction can be computed from the height of the sticky header and the height of the scroll view.
The heights can be measured using .onGeometryChange
.
You probably want to incoporate the vertical spacing of the grid into the calculation too.
These changes are needed for it to work this way:
@State private var scrollHeight = CGFloat.zero
@State private var stickyHeaderHeight = CGFloat.zero
.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).
LazyVGrid
:let spacing: CGFloat = 10
LazyVGrid(columns: cols, spacing: spacing, pinnedViews: [.sectionHeaders]) {
// ...
}
private var anchor: UnitPoint {
scrollHeight > 0 && stickyHeaderHeight > 0
? UnitPoint(x: 0.5, y: (stickyHeaderHeight + spacing) / scrollHeight)
: .top
}
.scrollPosition
:.scrollPosition(id: $scrollPosition, anchor: anchor)
.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)
}
}