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.
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) { } }}
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.
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 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!
Post a Comment