The following code fails to update scrollPosition in SwiftUI ScrollView when the user auto-scrolls, i.e. swipes the finger to scroll and picks up the finger immediately. While the scroll view continues scrolling till it decelerates and stops as the finger has been lifted, the scrollPosition is not seen updated in this time. I have not been able to figure out why it is happening.
import SwiftUI
import UIKit
struct ScrubberConfig2 {
var count:Int
var majorTickInterval:Int
var spacing:CGFloat
let labelFormatter: ((Int) -> String)?
init(count: Int, majorTickInterval: Int, spacing: CGFloat, labelFormatter: ((Int) -> String)? = nil) {
self.count = count
self.majorTickInterval = majorTickInterval
self.spacing = spacing
self.labelFormatter = labelFormatter
}
}
struct VerticalScrubber2: View {
var config: ScrubberConfig2
@Binding var value: Int
let tickHeight: CGFloat = 5 // 👈 added
@State private var isUserInteracting: Bool = false // New state to track user interaction
@State private var isScrollPositionSet = false // 👈 added
@State private var scrollPosition:Int?
var body: some View {
GeometryReader { geometry in
let verticalPadding = (geometry.size.height - tickHeight) / 2
ZStack(alignment: .trailing) {
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: config.spacing) {
ForEach(0...config.count, id: \.self) { index in
horizontalTickMark(for: index)
.frame(height: tickHeight)
.id(index)
}
}
.frame(width: 80)
.scrollTargetLayout()
}
.defaultScrollAnchor(.center)
.scrollTargetBehavior(.viewAligned)
.scrollPosition(id: $scrollPosition, anchor: .center)
.contentMargins(.vertical, verticalPadding)
Capsule()
.frame(width: 32, height: 3)
.foregroundColor(.accentColor)
.shadow(color: .accentColor.opacity(0.3), radius: 3, x: 0, y: 1)
}
.frame(width: 100)
.onAppear {
scrollPosition = value
}
.onChange(of: value, { oldValue, newValue in
if scrollPosition != newValue, !isUserInteracting {
print("📥 Value changed: \(oldValue) → \(newValue)")
scrollPosition = newValue
}
})
.onChange(of: scrollPosition, initial: true, { oldValue, newValue in
if let newValue = newValue, value != newValue, isUserInteracting {
print("👆 ScrollPosition changed: \(oldValue ?? -1) → \(newValue)")
value = newValue
}
})
.onScrollPhaseChange { oldPhase, newPhase in
if newPhase == .interacting {
isUserInteracting = true
} else {
isUserInteracting = false
}
}
}
}
private func horizontalTickMark(for index: Int) -> some View {
let isMajorTick = index % config.majorTickInterval == 0
return HStack(spacing: 8) {
Rectangle()
.fill(isMajorTick ? Color.accentColor : Color.gray.opacity(0.5))
.frame(width: isMajorTick ? 24 : 12, height: isMajorTick ? 2 : 1)
if isMajorTick {
Text(labelText(for: index))
.font(.system(size: 12, weight: .medium))
.foregroundColor(.primary)
.fixedSize()
}
}
.frame(maxWidth: .infinity, alignment: .trailing)
.padding(.trailing, 8)
}
private func labelText(for index: Int) -> String {
if let formatter = config.labelFormatter {
return formatter(index)
} else {
// Default: show index / steps * 5
let tickValue = index
return "\(tickValue)"
}
}
}
struct VerticalScrubberPreview2: View {
@State private var value: Int = 0
private let config = ScrubberConfig2(count: 100, majorTickInterval: 5, spacing: 5)
var body: some View {
Text("Vertical Scrubber (0–100 in steps of 5)")
.font(.title2)
.padding()
HStack(spacing: 30) {
VerticalScrubber2(config: config, value: $value)
.frame(width:120, height: 300)
.background(Color(.systemBackground))
.border(Color.gray.opacity(0.3))
VStack {
Text("Current Value:")
.font(.headline)
Text("\(value)")
.font(.system(size: 36, weight: .bold))
.padding()
}
Spacer()
}
.padding()
}
}
#Preview {
VerticalScrubberPreview2()
}

.onChangehandlers to propagate changes between thescrollPositionandvalue. Why not start with the solution you already accepted and then focus on new issues that have not already been addressed?Intinstead ofCGFloatwhich I now tried. It looks like this scrubber always gives some issue or the other. The point of this question is why "autoscroll" is not updatingscrollPosition?isUserInteractingby not considering all cases fornewPhase.