Custom PinnedScrollableViews

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 ScrollView needs 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?

Answered by Frameworks Engineer in 892575022

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:

  1. LazyVStacks have arbitrary content offsets, so reading the offset may be fragile!
  2. You'll want something to cover the "unsafe area" at the top in this case, since the LazyVStack may 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!

Hi there!

That's many moving pieces, but it sounds like a set-up like this should be possible.

Do you have a small code sample to show the set-up?

Otherwise: is this a native iOS navigation bar? Why are you toggling the safe area visibility?

Here's a sample code:

struct PinnedHeaderView: View {
    @State private var topSafeAreaPadding = CGFloat.zero
    @State private var edges: Edge.Set = [.top]
    private let scrollSpaceName = "CustomScrollViewSpace"

    var body: some View {
        GeometryReader { proxy in
            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)
                            .coordinateSpace(name: scrollSpaceName)
                            .background {
                                GeometryReader { proxy in
                                    Color.clear
                                        .onChange(of: proxy.frame(in: .named(scrollSpaceName))) { oldValue, newValue in
                                            let threshold = 100 +  newValue.origin.y - topSafeAreaPadding
                                            self.edges = threshold <= 0 ? [] : [.top]
                                        }
                                }
                            }
                    }
                }
            }
            .ignoresSafeArea(edges: edges)
            .onGeometryChange(for: CGFloat.self, of: \.safeAreaInsets.top) { newValue in
                self.topSafeAreaPadding = newValue
            }
        }
    }
}

And here's the code from scene(_:willConnectTo:options:):

guard let scene = (scene as? UIWindowScene) else { return }
window = Window(windowScene: scene)
        let view = PinnedHeaderView()
        let viewController = UIHostingController(rootView: view)
        let navigationController = UINavigationController(rootViewController: viewController)
        window?.rootViewController = navigationController
        window?.makeKeyAndVisible()

Just one typo on line 25, it should be:

let threshold = newValue.origin.y - topSafeAreaPadding - 100

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:

  1. LazyVStacks have arbitrary content offsets, so reading the offset may be fragile!
  2. You'll want something to cover the "unsafe area" at the top in this case, since the LazyVStack may 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!

Custom PinnedScrollableViews
 
 
Q