In a simple scenario, it's possible to have a ScrollView that contains a LazyVStack where the headers or footers of Section gets pinned to the top safe area.
In a more complicated scenario, where
- The
ScrollViewneeds to ignore the top safe area - The navigation bar is hidden until a certain view, call it Foo, has gone behind what used to be the top safe area, i.e., only about 100p of the view is visible which would be invisible if the navigation bar wasn't transparent/hidden.
- The navigation bar becomes visible once Foo is scrolled up to a threshold that was explained earlier
- A second view, call is Bar, that's positioned below Foo, should never go behind the navigation bar, i.e., it should be pinned to the bottom of the navigation bar
I managed to achieve this behaviour by having a State for the ignored safe area edges that gets updated based on how far Foo is scrolled. However, I get a jumpy behaviour because the ScrollView tries to adjust its contentOffset/contentInset based on the new top safe area. This jumpy behaviour is very hard to compensate especially when user scrolls very fast.
In UIKit, I could achieve this behaviour by overriding viewSafeAreaInsetsDidChangeand tweaking the contentInset, and contentOffset on a collection/table view.
Maybe I need to rethink my approach in a declarative way that aligns better with SwiftUI. Any thoughts on how to achieve this?
There's a bit of an ugly way to do this with .visualEffects on your header!
struct PinnedHeaderView: View {
@State private var topSafeArea = CGFloat.zero
var body: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0, pinnedViews: .sectionHeaders) {
Section {
Color.red
.frame(height: 800)
}
Section {
Color.blue
.frame(height: 800)
} header: {
Color.green
.frame(height: 100)
.visualEffect { effect, proxy in
let minY = proxy.frame(in: .scrollView).minY
let stickyOffset = max(0, min(topSafeArea, topSafeArea - minY))
return effect.offset(y: stickyOffset)
}
}
}
}
.ignoresSafeArea(edges: .top)
.onGeometryChange(
for: CGFloat.self, of: \.safeAreaInsets.top
) { newValue in
self.topSafeArea = newValue
}
}
}
Two points of guidance, though:
LazyVStacks have arbitrary content offsets, so reading the offset may be fragile!- You'll want something to cover the "unsafe area" at the top in this case, since the
LazyVStackmay unload the previous header when a new one comes in before it completely leaves the viewport.
There's a great talk on LazyStacks if you're interested on why this happens!