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()
}