Adding scroll offset tracking to a SwiftUI ScrollView

Feb 6, 2023 · Follow on Twitter and Mastodon swiftuiscrollviewopen-source

The SwiftUI ScrollView is currently quite limited, and for instance doesn’t let us detect the current scroll offset. In this post, let’s look at how to add offset tracking, which we’ll then use to build fun things.

How to track scroll offset

To add scroll offset tracking to a scroll view, we must first define a coordinate space that we’ll use to read the frame of the scroll view content:

enum ScrollOffsetNamespace {

    static let namespace = "scrollView"
}

We must then define a preference key that we can use to track the offset, by adding a preference modifier to the scroll view content:

struct ScrollOffsetPreferenceKey: PreferenceKey {

    static var defaultValue: CGPoint = .zero

    static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {}
}

We can now use these types to create a view that can detect the offset when it’s added to a scroll view:

struct ScrollViewOffsetTracker: View {

    var body: some View {
        GeometryReader { geo in
            Color.clear
                .preference(
                    key: ScrollOffsetPreferenceKey.self,
                    value: geo.frame(in: .named(ScrollOffsetNamespace.namespace)).origin
                )
        }
        .frame(height: 0)
    }
}

This view just applies a preference with the preference key and namespace to a Color, then wraps the color in a GeometryReader that is used to get the frame of the view.

To make sure that this view doesn’t conflict with the ScrollView content, we also apply a zero height frame to the geometry reader.

For this to work, we also need to add a coordinateSpace to the scroll view and listen for preference changes to the ScrollOffsetPreferenceKey. We can define this as a ScrollView extension:

private extension ScrollView {

    func withOffsetTracking(
        action: @escaping (_ offset: CGPoint) -> Void
    ) -> some View {
        self.coordinateSpace(name: ScrollOffsetNamespace.namespace)
            .onPreferenceChange(ScrollOffsetPreferenceKey.self, perform: action)
    }
}

We can now put things together by using the offset tracking view and the scroll view extension like this:

ScrollView(.vertical) {
    ZStack(alignment: .top) {
        ScrollViewOffsetTracker()
        // Insert scroll view content here
    }
}.withOffsetTracking(action: { print("Offset: \($0)") })

The action scroll offset is now continuously sent to the action as the scroll view is scrolled. You could use this to fade out some content in the header, present additional views as content is scrolled etc.

Creating a new scroll view component

Since you must always use a ZStack with .top alignment, an offset tracker view and a view modifier, I found it easier and less error-prone to create a separate scroll view:

public struct ScrollViewWithOffset<Content: View>: View {

    public init(
        _ axes: Axis.Set = .vertical,
        showsIndicators: Bool = true,
        onScroll: ScrollAction? = nil,
        @ViewBuilder content: @escaping () -> Content
    ) {
        self.axes = axes
        self.showsIndicators = showsIndicators
        self.onScroll = onScroll ?? { _ in }
        self.content = content
    }

    private let axes: Axis.Set
    private let showsIndicators: Bool
    private let onScroll: ScrollAction
    private let content: () -> Content

    public typealias ScrollAction = (_ offset: CGPoint) -> Void

    public var body: some View {
        ScrollView(axes, showsIndicators: showsIndicators) {
            ZStack(alignment: .top) {
                ScrollViewOffsetTracker()
                content()
            }
        }.withOffsetTracking(action: onScroll)
    }
}

You can then just use ScrollViewWithOffset instead of having to specify all required components every time you want to use offset tracking:

struct MyView: View {

    @State
    private var scrollOffset: CGPoint = .zero
    
    var body: some View {
        NavigationView {
            ScrollViewWithOffset(onScroll: handleScroll) {
                LazyVStack {
                    ForEach(1...100, id: \.self) {
                        Divider()
                        Text("\($0)").frame(maxWidth: .infinity)
                    }
                }
            }.navigationTitle(offsetTitle)
        }
    }

    func handleScroll(_ offset: CGPoint) {
        self.scrollOffset = offset
    }
}

Scrolling the scroll view will continuously update the scrollOffset state property, which we present in the navigation title. This will initially look like this:

A screenshot of an app where offset is zero

As you then scroll the content in the scroll view, the scrollOffset state property will be updated and the new value rendered in the navigation bar title:

A screenshot of an app where scroll offset is not zero

That’s basically it. You now have a ScrollView replacement that let’s you define an action that provides you with the scroll offset, which you can use in more exciting ways than to just present it as above.

Binding vs action

If you have seen similar code before and wonder why I’m not using a binding instead of the action, I find using a binding confusing when it’s not possible to change the value to scroll to a certain offset.

Since the binding is thus in fact read-only and writing to it would lead to bugs, I decided use an action instead, and provide it with the scroll offset.

If we’d find a way to scroll to a certain offset, I’d be happy to change this to a binding. Until then, I think the action approach is perfectly fine, although perhaps not as convenient as using a binding.

Conclusion

ScrollViewWithOffset replaces ScrollView and lets you track scroll offset by just providing it with an additional onScroll action.

I have added ScrollViewWithOffset to my newly released ScrollKit library. You can find the source code here. If you give it a try, I’d love to hear what you think.

Big thanks to Daniel Arden for providing me with this nice offset tracking and for joining me in the my efforts to build a fun scroll view feature that we’ll uncover in the next post. Stay tuned!

Discussions

Please share any ideas, feedback or comments you may have in the Disqus section below, or by replying to this tweet or this toot.

Follow for more

If you found this interesting, follow the Twitter and Mastodon accounts for more content like this, and to be notified when new content is published.