I have a discrete scrubber implementation using ScrollView
in SwiftUI that works but fails in the end points sometimes. For instance, scrolling it all the way to bottom shows a value of 87 instead of 100. But if scrolled down by tapping +
button incrementally till it reaches the end, it will show the correct value of 100 when it reaches the end. But tapping -
button doesn't scrolls the scrubber to jump to the correct value till it is tapped two times. I understand this has only to do with scroll target behaviour of .viewAligned
but don't understand what exactly is the issue.
import SwiftUI
struct VerticalScrubber: View {
var config: ScrubberConfig
@Binding var value: CGFloat
@State private var scrollPosition: Int?
var body: some View {
GeometryReader { geometry in
let verticalPadding = geometry.size.height / 2 - 8
ZStack(alignment: .trailing) {
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: config.spacing) {
ForEach(0...(config.steps * config.count), id: \.self) { index in
horizontalTickMark(for: index)
.id(index)
}
}
.frame(width: 80)
.scrollTargetLayout()
.safeAreaPadding(.vertical, verticalPadding)
}
.scrollTargetBehavior(.viewAligned)
.scrollPosition(id: $scrollPosition, anchor: .top)
Capsule()
.frame(width: 32, height: 3)
.foregroundColor(.accentColor)
.shadow(color: .accentColor.opacity(0.3), radius: 3, x: 0, y: 1)
}
.frame(width: 100)
.onAppear {
DispatchQueue.main.async {
scrollPosition = Int(value * CGFloat(config.steps))
}
}
.onChange(of: value, { oldValue, newValue in
let newIndex = Int(newValue * CGFloat(config.steps))
print("New index \(newIndex)")
if scrollPosition != newIndex {
withAnimation {
scrollPosition = newIndex
print("\(scrollPosition)")
}
}
})
.onChange(of: scrollPosition, { oldIndex, newIndex in
guard let pos = newIndex else { return }
let newValue = CGFloat(pos) / CGFloat(config.steps)
if abs(value - newValue) > 0.001 {
value = newValue
}
})
}
}
private func horizontalTickMark(for index: Int) -> some View {
let isMajorTick = index % config.steps == 0
let tickValue = index / config.steps
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("\(tickValue * 5)")
.font(.system(size: 12, weight: .medium))
.foregroundColor(.primary)
.fixedSize()
}
}
.frame(maxWidth: .infinity, alignment: .trailing)
.padding(.trailing, 8)
}
}
#Preview("Vertical Scrubber") {
struct VerticalScrubberPreview: View {
@State private var value: CGFloat = 0
private let config = ScrubberConfig(count: 20, steps: 5, spacing: 8)
var body: some View {
VStack {
Text("Vertical Scrubber (0–100 in steps of 5)")
.font(.title2)
.padding()
HStack(spacing: 30) {
VerticalScrubber(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 * 5, specifier: "%.0f")")
.font(.system(size: 36, weight: .bold))
.padding()
HStack {
Button("−5") {
let newValue = max(0, value - 1)
if value != newValue {
value = newValue
UISelectionFeedbackGenerator().selectionChanged()
}
print("Value \(newValue), \(value)")
}
.disabled(value <= 0)
Button("+5") {
let newValue = min(CGFloat(config.count), value + 1)
if value != newValue {
value = newValue
UISelectionFeedbackGenerator().selectionChanged()
}
print("Value \(newValue), \(value)")
}
.disabled(value >= CGFloat(config.count))
}
.buttonStyle(.bordered)
}
}
Spacer()
}
.padding()
}
}
return VerticalScrubberPreview()
}
id: \.self
inForEach
instead of real identifier keypath and.onChange
instead of Binding are likely contributing factors to the issue. AlsoDispatchQueue.main.async
inside structs that have no lifetime is also asking for trouble.id
with anything else or removing DispatchAsync main which is only calledonAppear
the very first time.GeometryReader
? Please feel free to post your answer withoutGeometryReader
.