ScrollView Content Offsets with SwiftUI
September 30, 2019
Scroll views are an incredibly important view type for displaying content on all the Apple platforms. Unfortunately, ScrollView
in SwiftUI has some pretty glaring and difficult to work around limitations. Some of the pretty important features lacking out of the box are:
- Offset change callback (via
UIScrollViewDelegate.scrollViewDidScroll(_ scrollView: UIScrollView)
in UIKit) - Scroll end point control for pagination (via
UIScrollViewDelegate.scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>)
in UIKit) - Animation / setting of offset (via
UIScrollView.setContentOffset(_ contentOffset: CGPoint, animated: Bool)
in UIKit)
Luckily, for #1 we can use GeometryReader
and our own container view to achieve the same view containment behavior as ScrollView
, but add a Binder<CGPoint>
which can give us the offset.
To begin, we start with all the same API available to ScrollView
:
public struct OffsetScrollView<Content>: View where Content : View {
/// The content of the scroll view.
public var content: Content
/// The scrollable axes.
///
/// The default is `.vertical`.
public var axes: Axis.Set
/// If true, the scroll view may indicate the scrollable component of
/// the content offset, in a way suitable for the platform.
///
/// The default is `true`.
public var showsIndicators: Bool
...
}
But we want to add a Binder<CGPoint>
to the mix and an initializer that initializes all our properties and a private @State
to hold the initial offset:
/// The initial offset of the view as measured in the global frame
@State private var initialOffset: CGPoint?
/// The offset of the scroll view updated as the scroll view scrolls
@Binding public var offset: CGPoint
public init(_ axes: Axis.Set = .vertical, showsIndicators: Bool = true, offset: Binding<CGPoint> = .constant(.zero), @ViewBuilder content: () -> Content) {
self.axes = axes
self.showsIndicators = showsIndicators
self._offset = offset
self.content = content()
}
Now in the body
property, we’ll want to return a normal ScrollView
, but add our own view above the provided content
that can utilize GeometryReader
to get a callback for position. By passing the frame origin of this empty, 0x0 view into our Binder<CGPoint>
we can get a callback for geometry changes that will allow us to update the binding and subsequently any listeners to the binding:
public var body: some View {
ScrollView(axes, showsIndicators: showsIndicators) {
VStack(alignment: .leading, spacing: 0) {
GeometryReader { geometry in
Run {
let globalOrigin = geometry.frame(in: .global).origin
self.initialOffset = self.initialOffset ?? globalOrigin
let initialOffset = (self.initialOffset ?? .zero)
let offset = CGPoint(x: globalOrigin.x - initialOffset.x, y: globalOrigin.y - initialOffset.y)
self.offset.wrappedValue = offset
}
}.frame(width: 0, height: 0)
content
}
}
}
You’ll notice a Run
‘view’ which is pretty simple and lets us just execute a bit of code without affecting the layout:
struct Run: View {
let block: () -> Void
var body: some View {
DispatchQueue.main.async(execute: block)
return AnyView(EmptyView())
}
}
Putting it all together, we now have a way to access the offset of the ScrollView
and update a @State
wrapped property. This can be used within other Views in our hierarchy to achieve some interesting effects.
In this watchOS example, we can use it to achieve an effect similar to the built-in News app where as you scroll the content, a header recedes by changing opacity and position based on the scroll offset.
This kind of behavior wasn’t possible on previous versions of watchOS, so this serves as a perfect example of why watchOS + SwiftUI is an awesome combo! 🎉
If you’d like to mess around with the watchOS project demonstrating this, here’s the zip
Update 10/28/19
Thanks to @dmcgloin on Twitter for pointing out that Xcode 11.2 beta 2 was throwing a helpful warning that the original code was “Modifying state during view update, this will cause undefined behavior.” One workaround was to kick the closure inside the Run
view to run asynchronously. The above inline code and the sample zip have been updated with this workaround.