04
Jan
2022
SwiftUI ScrollView Scroll Offset
Reading time: 2 min
This recipe shows how to get current scroll offset of a SwiftUI ScrollView. The necessary functionality is wrapped in a new component, ObservableScrollView, which works exactly the same as ScrollView
does, but also exposes its current scroll offset in a binding.
The end result looks like this:
The recipe goes as follows:
- Put a
GeometryReader
in theScrollView
's content'sbackground
. This will allow you to track the content's frame and its changes. (The same trick with aGeometryReader
in the background can also be used to measure SwiftUI views.) - The frame's
minY
property is the current offset. Negate it to track the offset as a positive number. - Safely propagate the new offset value via a
PreferenceKey
to the binding. - For the sake of completeness, we'll include a
ScrollViewProxy
so that you can scrollObservableScrollView
programatically.
Here's the code:
// Simple preference that observes a CGFloat.
struct ScrollViewOffsetPreferenceKey: PreferenceKey {
static var defaultValue = CGFloat.zero
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value += nextValue()
}
}
// A ScrollView wrapper that tracks scroll offset changes.
struct ObservableScrollView<Content>: View where Content : View {
@Namespace var scrollSpace
@Binding var scrollOffset: CGFloat
let content: (ScrollViewProxy) -> Content
init(scrollOffset: Binding<CGFloat>,
@ViewBuilder content: @escaping (ScrollViewProxy) -> Content) {
_scrollOffset = scrollOffset
self.content = content
}
var body: some View {
ScrollView {
ScrollViewReader { proxy in
content(proxy)
.background(GeometryReader { geo in
let offset = -geo.frame(in: .named(scrollSpace)).minY
Color.clear
.preference(key: ScrollViewOffsetPreferenceKey.self,
value: offset)
})
}
}
.coordinateSpace(name: scrollSpace)
.onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in
scrollOffset = value
}
}
}
Then, you can simple use it like this:
struct ScrollViewOffset: View {
@State var scrollOffset = CGFloat.zero
var body: some View {
NavigationView {
ObservableScrollView(scrollOffset: $scrollOffset) { proxy in
LazyVStack(alignment: .leading) {
ForEach(1..<50) { index in
Text("Row \(index)")
}
}
}
.navigationBarTitle("Scroll offset: \(scrollOffset)",
displayMode: .inline)
}
}
}