I'm trying to create sticky header with manual scroll to functionality (eg. categories bar allowing user to move to chosen section). I have issues with one part of that functionallity. While ScrollViewReader
and proxy.scrollTo
works as intended; it keeps scrolling under sticky header instead of below it. Image one shows how it works, image two shows what is expected. As you can see swiftUI basically ignores existence of header, treating it as separate Z layer.
I would love solution to work on iOS 15.
Code to reproduce example:
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
ScrollViewReader(content: { proxy in
ScrollView {
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
.onTapGesture {
proxy.scrollTo(sections[2].hashValue, anchor: .top)
}
Section {
NestedSectionsView()
.frame(maxWidth: .infinity)
} header: {
Header().opacity(0.5)
}
}
}
})
}
.padding()
}
}
#Preview {
ContentView()
}
let sections = [
SectionData(header: "First Section", items: Array(repeating: "Item 1", count: 5)),
SectionData(header: "Second Section", items: Array(repeating: "Item 2", count: 5)),
SectionData(header: "Third Section", items: Array(repeating: "Item 3", count: 10))
]
struct NestedSectionsView: View {
var body: some View {
LazyVStack(spacing: 10) {
ForEach(sections, id: \.hashValue) { section in
SectionView(section: section)
}
}
.padding()
.navigationBarTitle("Nested Sections")
}
}
struct SectionData: Identifiable, Hashable {
let id = UUID()
let header: String
let items: [String]
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
struct SectionView: View {
let section: SectionData
var body: some View {
VStack(alignment: .leading, spacing: 3) {
Text(section.header)
.font(.headline)
.frame(height: 50)
.frame(maxWidth: .infinity)
.background(Color.green)
.id(section.hashValue)
ForEach(section.items, id: \.self) { item in
Text(item)
.frame(height: 140)
.frame(maxWidth: .infinity)
.background(Color.red)
}
}
}
}
struct Header: View {
var body: some View {
VStack {
Image(systemName: "photo")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: UIScreen.main.bounds.width, height: 100)
.padding()
Text("Example Text")
.font(.title)
.foregroundColor(.black)
.frame(width: UIScreen.main.bounds.width, height: 100, alignment: .center)
}.background(Color.yellow)
}
}
How does it work now (when tapping scroll to id of section 3)
How does I would like it to work (scrolls so section title is visible, and not covered by sticky header)
The documentation for
.scrollTo
explains how theanchor
works:So when the anchor is
.top
as you have it, the top of the target view will be aligned with the top of theScrollView
. This is exactly what you are seeing -> working as designed.What you want is for the target section to be below the top of the
ScrollView
by the height of the header. One way to achieve this is to change thescrollTo
target to something that is at the end of the preceding section.To see it working, try the following changes to your example:
1. Add an additional id to
SectionData
, to use as thescrollTo
target2. Add a placeholder to the bottom of the background of each
SectionView
While we're at it, we can fix the
ForEach
to prevent errors in the console about duplicate ids.If you don't know the height that needs to be reserved then you could use a hidden copy of
Header
instead:3. Change the scrollTo target to refer to the placeholder at the bottom of the preceding section
You might like to add some animation too:
4. Change
NestedSectionsView
to useVStack
instead ofLazyVStack
If a
LazyVStack
is used then the placeholders that were added to the background in step 2 do not exist when aSectionView
is out-of-view, so they can't be used for scrolling to. Changing to aVStack
resolves this:It now scrolls to the desired position: