I have a discrete scrubber implementation (range 0-100) using ScrollView in SwiftUI that fails on the end points. For instance, scrolling it all the way to bottom shows a value of 87 instead of 100. Or if scrolling down by tapping + button incrementally till it reaches the end, it will show the correct value of 100 when it reaches the end. But now, tapping minus button doesn't scrolls the scrubber back till minus button is clicked thrice.
I understand this has only to do with scroll target behaviour of .viewAligned but don't understand what exactly is the issue, or if its a bug in SwiftUI.
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()
}
For example:
import SwiftUI
struct StepCount: Equatable, Hashable, ExpressibleByIntegerLiteral {
let value: Int
init(_ value: Int) {
self.value = max(1, value)
}
init(integerLiteral value: Int) {
self.value = max(1, value)
}
}
struct ScrubberRange: Equatable, Hashable {
let minimum: Double
let maximum: Double
let stepSize: Double
init(minimum: Double = 0, maximum: Double, stepSize: Double = 1) {
self.minimum = minimum
self.maximum = max(minimum, maximum)
self.stepSize = max(0.1, stepSize)
}
var totalSteps: Int {
Int((maximum - minimum) / stepSize)
}
func value(from normalizedValue: Double) -> Double {
minimum + (normalizedValue * (maximum - minimum))
}
func normalizedValue(from value: Double) -> Double {
guard maximum > minimum else { return 0 }
return max(0, min(1, (value - minimum) / (maximum - minimum)))
}
}
struct ScrubberConfig: Equatable, Hashable {
let range: ScrubberRange
let majorTickCount: Int
let minorStepsPerMajor: StepCount
let spacing: CGFloat
let showLabels: Bool
let labelFormatter: NumberFormatter?
init(
range: ScrubberRange,
majorTickCount: Int = 10,
minorStepsPerMajor: StepCount = 5,
spacing: CGFloat = 8,
showLabels: Bool = true,
labelFormatter: NumberFormatter? = nil
) {
self.range = range
self.majorTickCount = max(1, majorTickCount)
self.minorStepsPerMajor = minorStepsPerMajor
self.spacing = max(1, spacing)
self.showLabels = showLabels
self.labelFormatter = labelFormatter ?? {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.maximumFractionDigits = 0
return formatter
}()
}
var totalTickCount: Int {
majorTickCount * minorStepsPerMajor.value + 1
}
func value(for tickIndex: Int) -> Double {
let normalizedIndex = Double(tickIndex) / Double(totalTickCount - 1)
return range.value(from: normalizedIndex)
}
func isMajorTick(_ index: Int) -> Bool {
index % minorStepsPerMajor.value == 0
}
func label(for tickIndex: Int) -> String? {
guard isMajorTick(tickIndex) && showLabels else { return nil }
let value = self.value(for: tickIndex)
return labelFormatter?.string(from: NSNumber(value: value))
}
}
struct ScrubberState: Equatable {
private var _currentValue: Double
private let config: ScrubberConfig
init(initialValue: Double = 0, config: ScrubberConfig) {
self.config = config
self._currentValue = Self.clampValue(initialValue, to: config.range)
}
var currentValue: Double {
get { _currentValue }
set { _currentValue = Self.clampValue(newValue, to: config.range) }
}
var normalizedValue: Double {
config.range.normalizedValue(from: currentValue)
}
var currentTickIndex: Int {
Int(normalizedValue * Double(config.totalTickCount - 1))
}
var isAtMinimum: Bool {
abs(currentValue - config.range.minimum) < 0.001
}
var isAtMaximum: Bool {
abs(currentValue - config.range.maximum) < 0.001
}
mutating func moveToNextMajorTick() {
let currentIndex = currentTickIndex
let nextMajorIndex = ((currentIndex / config.minorStepsPerMajor.value) + 1) * config.minorStepsPerMajor.value
if nextMajorIndex < config.totalTickCount {
currentValue = config.value(for: nextMajorIndex)
}
}
mutating func moveToPreviousMajorTick() {
let currentIndex = currentTickIndex
let previousMajorIndex = max(0, ((currentIndex / config.minorStepsPerMajor.value) - 1) * config.minorStepsPerMajor.value)
currentValue = config.value(for: previousMajorIndex)
}
mutating func step(by amount: Double) {
currentValue += amount
}
private static func clampValue(_ value: Double, to range: ScrubberRange) -> Double {
max(range.minimum, min(range.maximum, value))
}
}
@Observable
class ScrubberData {
var state: ScrubberState
let config: ScrubberConfig
var scrollPosition: TickInfo.ID? {
didSet {
guard let pos = scrollPosition else { return }
let newValue = config.value(for: pos)
if abs(currentValue - newValue) > 0.001 {
currentValue = newValue
}
}
}
init(config: ScrubberConfig, initialValue: Double = 0) {
self.config = config
self.state = ScrubberState(initialValue: initialValue, config: config)
}
var currentValue: Double {
get { state.currentValue }
set { state.currentValue = newValue }
}
var formattedCurrentValue: String {
config.labelFormatter?.string(from: NSNumber(value: currentValue)) ?? "\(currentValue)"
}
func tickInfo(for index: Int) -> TickInfo? {
guard index >= 0 && index < config.totalTickCount else { return nil }
return TickInfo(
index: index,
value: config.value(for: index),
isMajor: config.isMajorTick(index),
label: config.label(for: index)
)
}
var allTicks: [TickInfo] {
(0..<config.totalTickCount)
.compactMap { tickInfo(for: $0) }
}
}
struct TickInfo: Identifiable, Equatable {
let id: Int
let index: Int
let value: Double
let isMajor: Bool
let label: String?
init(index: Int, value: Double, isMajor: Bool, label: String?) {
self.id = index
self.index = index
self.value = value
self.isMajor = isMajor
self.label = label
}
}
extension ScrubberData {
static func percentage(initialValue: Double = 0) -> ScrubberData {
let range = ScrubberRange(minimum: 0, maximum: 100, stepSize: 5)
let config = ScrubberConfig(
range: range,
majorTickCount: 20,
minorStepsPerMajor: 5
)
return ScrubberData(config: config, initialValue: initialValue)
}
static func range(
from minimum: Double,
to maximum: Double,
stepSize: Double = 1,
majorTicks: Int = 10,
minorSteps: Int = 5,
initialValue: Double? = nil
) -> ScrubberData {
let range = ScrubberRange(minimum: minimum, maximum: maximum, stepSize: stepSize)
let config = ScrubberConfig(
range: range,
majorTickCount: majorTicks,
minorStepsPerMajor: StepCount(minorSteps)
)
return ScrubberData(config: config, initialValue: initialValue ?? minimum)
}
}