Tracking Scroll Position in SwiftUI: A Guide to Observing Content Offset in ScrollView

Bhoopendra Umrao
3 min readJun 9, 2024

--

SwiftUI offers developers a robust framework for crafting user interfaces across Apple platforms. A frequent requirement in app development is monitoring the content offset of a scroll view, which is essential for features like custom animations, lazy loading, or synchronizing scroll positions between multiple views. While UIKit uses the UIScrollViewDelegate for this purpose, SwiftUI employs a different approach. This article will guide you through the steps to observe the content offset in a SwiftUI ScrollView, leveraging GeometryReader and custom view preferences.

Understanding ScrollView in SwiftUI

In SwiftUI, ScrollView is a container view that allows for vertical or horizontal scrolling of its content. It doesn't provide direct access to the scroll offset out of the box, but we can achieve this by leveraging a combination of GeometryReaderand custom view preferences.

Using GeometryReader

GeometryReader is a view that provides the size and position of the container it is placed in. By embedding it inside a ScrollView, we can determine the current scroll position.

Creating a ScrollView with Observable Content Offset

Let’s dive into the code and create a custom ScrollView that allows us to observe the content offset.

  1. Create a ContentOffsetKey Preference Key:

Preference keys are used in SwiftUI to propagate values up the view hierarchy. We’ll use this to pass the scroll offset from the child views to the parent view.

import SwiftUI

struct ContentOffsetKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value += nextValue()
}
}

We use SwiftUI’s preference system because it allows us to handle the limitations of GeometryReader. During the view update process, GeometryReader provides layout information, but SwiftUI does not permit direct state changes at this stage to ensure a consistent and stable view rendering process. To work around this, we use preferences to pass values asynchronously. This means we can extract the CGPoint values from GeometryReader, propagate them up the view hierarchy using preferences, and then update our state bindings safely and effectively outside the immediate rendering process. This approach ensures that our content offset values are delivered and assigned correctly without disrupting SwiftUI’s state management rules.

2. Build a Custom ScrollView:

We will create a custom ScrollView that uses GeometryReader to track the content offset and update a state variable accordingly.

struct ObservableScrollView<Content: View>: View {
let content: Content
@Binding var contentOffset: CGFloat

init(contentOffset: Binding<CGFloat>, @ViewBuilder content: () -> Content) {
self._contentOffset = contentOffset
self.content = content()
}

var body: some View {
ScrollView {
content
.background {
GeometryReader { geometry in
Color.clear
.preference(key: ContentOffsetKey.self, value: geometry.frame(in: .named("scrollView")).minY)
}
}
}
.coordinateSpace(name: "scrollView")
.onPreferenceChange(ContentOffsetKey.self) { value in
self.contentOffset = value
}
}
}

3. Using the Custom ScrollView:

Now, we can use our ObservableScrollView in a SwiftUI view and bind it to a state variable to observe the content offset.

struct ContentView: View {
@State private var contentOffset: CGFloat = 0

var body: some View {
VStack {
Text("Content Offset: \(contentOffset)")
.padding()

ObservableScrollView(contentOffset: $contentOffset) {
VStack {
ForEach(0..<50) { i in
Text("Item \(i)")
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue.opacity(0.3))
.cornerRadius(8)
.padding(.horizontal)
.padding(.vertical, 4)
}
}
}
}
}
}

In this example, ContentView contains an ObservableScrollView that displays a list of items. The content offset is observed and displayed at the top of the view. As you scroll through the list, the content offset is updated in real-time.

Conclusion

Observing the content offset in a SwiftUI ScrollView involves a few steps but is quite manageable with the help of GeometryReader and preference keys. This technique can be extended and customized to suit various use cases, such as triggering animations based on the scroll position, loading additional content when nearing the end of the scroll view, and more. SwiftUI's declarative nature makes such tasks intuitive and straightforward once you get the hang of the available tools and patterns.

--

--

No responses yet