Articles, podcasts and news about Swift development, by John Sundell.

Observing the content offset of a SwiftUI ScrollView

Published on 30 Jan 2023
Discover page available: SwiftUI

When building various kinds of scrollable UIs, it’s very common to want to observe the current scroll position (or content offset, as UIScrollView calls it) in order to trigger layout changes, load additional data when needed, or to perform other kinds of actions depending on what content that the user is currently viewing.

However, when it comes to SwiftUI’s ScrollView, there’s currently (at the time of writing) no built-in way to perform such scrolling observations. While embedding a ScrollViewReader within a scroll view does enable us to change the scroll position in code, it strangely (especially given its name) doesn’t let us read the current content offset in any way.

One way to solve that problem would be to utilize the rich capabilities of UIKit’s UIScrollView, which — thanks to its delegate protocol and the scrollViewDidScroll method — provides an easy way to get notified whenever any kind of scrolling occurred. However, even though I’m normally a big fan of using UIViewRepresentable and the other SwiftUI/UIKit interoperability mechanisms, in this case, we’d have to write quite a bit of extra code to bridge the gap between the two frameworks.

That’s mainly because — at least on iOS — we can only embed SwiftUI content within a UIHostingController, not within a self-managed UIView. So if we wanted to build a custom, observable version of ScrollView using UIScrollView, then we’d have to wrap that implementation in a view controller, and then manage the relationship between our UIHostingController and things like the keyboard, the scroll view’s content size, safe area insets, and so on. Not impossibly by any means, but still, a fair bit of additional work and complexity.

So, let’s instead see if we can find a completely SwiftUI-native way to perform such content offset observations.

Resolving frames using GeometryReader

One thing that’s key to realize before we begin is that both UIScrollView and SwiftUI’s ScrollView perform their scrolling by offsetting a container that’s hosting our actual scrollable content. They then clip that container to their bounds to produce the illusion of the viewport moving. So if we can find a way to observe the frame of that container, then we’ll essentially have found a way to observe the scroll view’s content offset.

That’s where our good old friend GeometryReader comes in (wouldn’t be a proper SwiftUI layout workaround without it, right?). While GeometryReader is mostly used to access the size of the view that it’s hosted in (or, more accurately, that view’s proposed size), it also has another neat trick up its sleeve — in that it can be asked to read the frame of the current view relative to a given coordinate system.

To use that capability, let’s start by creating a PositionObservingView, which lets us bind a CGPoint value to the current position of that view relative to a CoordinateSpace that we’ll also pass in as an argument. Our new view will then embed a GeometryReader as a background (which will make that geometry reader take on the same size as the view itself) and will assign the resolved frame’s origin as our offset using a preference key — like this:

struct PositionObservingView<Content: View>: View {
    var coordinateSpace: CoordinateSpace
@Binding var position: CGPoint
    @ViewBuilder var content: () -> Content

    var body: some View {
        content()
            .background(GeometryReader { geometry in
                Color.clear.preference(
    key: PreferenceKey.self,
    value: geometry.frame(in: coordinateSpace).origin
)
            })
            .onPreferenceChange(PreferenceKey.self) { position in
                self.position = position
            }
    }
}

To learn more about how the @ViewBuilder attribute can be used when building custom SwiftUI container views, check out this article.

The reason we use SwiftUI’s preference system above is because our GeometryReader will be invoked as part of the view updating process, and we’re not allowed to directly mutate our view’s state during that process. So, by using a preference instead, we can deliver our CGPoint values to our view in an asynchronous fashion, which then lets us assign those values to our position binding.

Now all that we need to do is to implement the PreferenceKey type that’s used above, and we’ll be good to go:

private extension PositionObservingView {
    struct PreferenceKey: SwiftUI.PreferenceKey {
        static var defaultValue: CGPoint { .zero }

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

We don’t actually need to implement any kind of reduce algorithm above, since we’ll only have a single view delivering values using that preference key within any given hierarchy (since our implementation is entirely contained within our PositionObservingView).

Alright, so now we have a view that’s capable of reading and observing its own position within a given coordinate system. Let’s now use that view to build a ScrollView wrapper that’ll let us accomplish our original goal — to be able to read the current content offset within such a scroll view.

From position to content offset

Our new ScrollView wrapper will essentially have two responsibilities — one, it’ll need to convert the position of our inner PositionObservingView into the current scroll position (or content offset), and two, it’ll also need to define a CoordinateSpace that the inner view can use to resolve its position. Besides that, it’ll simply forward its configuration parameters to its underlying ScrollView, so that we can decide what axes we want each scroll view to operate on, and so that we can decide whether or not to display any scrolling indicators.

The good news is that converting our inner view’s position into content offset is as easy as negating both the x and y components of those CGPoint values. That’s because, as discussed earlier, a scroll view’s content offset is essentially just the distance that the container has been moved relative to the scroll view’s bounds.

So let’s go ahead and implement our custom scroll view, which we’ll name OffsetObservingScrollView (spelling out ContentOffset does feel a bit too verbose in this case):

struct OffsetObservingScrollView<Content: View>: View {
    var axes: Axis.Set = [.vertical]
    var showsIndicators = true
    @Binding var offset: CGPoint
    @ViewBuilder var content: () -> Content

    // The name of our coordinate space doesn't have to be
    // stable between view updates (it just needs to be
    // consistent within this view), so we'll simply use a
    // plain UUID for it:
    private let coordinateSpaceName = UUID()

    var body: some View {
        ScrollView(axes, showsIndicators: showsIndicators) {
            PositionObservingView(
                coordinateSpace: .named(coordinateSpaceName),
                position: Binding(
                    get: { offset },
                    set: { newOffset in
                        offset = CGPoint(
    x: -newOffset.x,
    y: -newOffset.y
)
                    }
                ),
                content: content
            )
        }
        .coordinateSpace(name: coordinateSpaceName)
    }
}

Note how we’re able to create a completely custom Binding for our inner view’s position parameter, by defining a getter and setter using closures. That’s a great option in situations like the one above, when we want to transform a value before assigning it to another Binding.

That’s it! We now have a drop-in replacement for SwiftUI’s built-in ScrollView which enables us to observe the current content offset — which we can then bind to any state property that we’d like, for example in order to change the layout of a header view, to report analytics events to our server, or to perform any other kind of scroll position-based operation. You can find a complete example that uses the above OffsetObservingScrollView in order to implement a collapsable header view right here.

I hope that you found this article useful. If you have any questions, comments, or feedback, then feel free to contact me on Mastodon, or send me an email.

Thanks for reading!