Synchronizing ScrollViews using SwiftUI
Synchronizing the scrolling positions between 2 (or more) ScrollView
s in SwiftUI is a fairly common problem. There are a few questions and answers online covering it. Some solutions are passable, but none are satisfying.
With the release of iOS 18.0 and macOS 15.0, SwiftUI has gained a few of new modifiers that make a more satisfying and efficient solution possible: scrollPosition
, onScrollPhaseChange
, and onScrollGeometryChange
.
The Old Way
The best existing solution, in my opinion, is to observe the scroll offset in one of the target scroll views using a GeometryReader
in the background and a Preference
value, and then apply the same offset using the offset
modifier to the other scroll view. It's hacky, but it worked.
The solution had a few drawbacks:
- High computational overhead due to constant
GeometryReader
calculations and its impact on the view graph; - Sometimes jittery scrolling experience;
- Complex implementation requiring coordinate spaces and preference keys.
The New Way: iOS 18 and macOS 15
SwiftUI has recently introduced a family of geometry observation APIs: onGeometryChange
, onScrollGeometryChange
, and onScrollPhaseChange
. The new APIs provide a much cleaner and more efficient solution.
For this solution, I'll assume our goal is to synchronize two scroll views.
- First, define scroll position state variables for each
ScrollView
:
struct ScrollInfo: Sendable {
var position: ScrollPosition = .init()
var isScrolledByUser: Bool = false
}
@State private var leftScrollInfo: ScrollInfo = .init()
@State private var rightScrollInfo: ScrollInfo = .init()
- Then, implement the synchronized
ScrollViews
:
ScrollView {
// Your content here
}
.scrollPosition($leftScrollInfo.position)
.onScrollGeometryChange(for: CGFloat.self) { geometry in
geometry.contentOffset.y + geometry.contentInsets.top
} action: { oldValue, newValue in
guard oldValue != newValue else { return }
if !rightScrollInfo.isScrolledByUser {
rightScrollInfo.position.scrollTo(y: newValue)
}
}
.onScrollPhaseChange { _, newPhase in
leftScrollInfo.isScrolledByUser = newPhase.isScrolling
rightScrollInfo.isScrolledByUser = !newPhase.isScrolling
}
ScrollView {
// Your content here
}
.scrollPosition($rightScrollInfo.position)
.onScrollGeometryChange(for: CGFloat.self) { geometry in
geometry.contentOffset.y + geometry.contentInsets.top
} action: { oldValue, newValue in
guard oldValue != newValue else { return }
if !leftScrollInfo.isScrolledByUser {
leftScrollInfo.position.scrollTo(y: newValue)
}
}
.onScrollPhaseChange { _, newPhase in
leftScrollInfo.isScrolledByUser = !newPhase.isScrolling
rightScrollInfo.isScrolledByUser = newPhase.isScrolling
}
For a more deep dive into the new geometry modifiers in SwiftUI, see my existing post: SwiftUI's New Geometry Modifiers & Best Practices: onGeometryChange, onScrollGeometryChange, and More.