Synchronizing ScrollViews using SwiftUI

Synchronizing ScrollViews using SwiftUI
Photo by Kelly Sikkema / Unsplash

Synchronizing the scrolling positions between 2 (or more) ScrollViews 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.

  1. 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()
  1. 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.

0:00
/0:07