SwiftUI scroll position targeting buggy with viewAligned scrollTargetBehavior

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

Answered by DTS Engineer in 847471022

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



I think they're a few ways to go here:

Depending on where your scroll anchor is you'll need to account for that. For example, if it's in the center you'll need to account for extra spacing at the top and the bottom of your scrollview.

One way to solve this is Implement a custom ScrollTargetBehavior to customize where the scroll gestures should end using its deceleration rate account for extra spacing at the top and bottom.

Another close alternative might be to use a drag gesture with a custom view. Either approach should get you close .

Accepted Answer

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



import SwiftUI

struct ContentView: View {
    @State private var scrubberData = ScrubberData.percentage(initialValue: 50)

    var body: some View {
        VStack {
            Text("Vertical Scrubber (0–100 in steps of 5)")
                .font(.title2)
                .padding()

            HStack(spacing: 30) {
                VerticalScrubber(scrubberData: scrubberData)
                    .frame(width: 120, height: 300)
                    .background(Color(.systemBackground))
                    .border(Color.gray.opacity(0.3))

                VStack {
                    Text("Current Value:")
                        .font(.headline)
                    Text(scrubberData.formattedCurrentValue)
                        .font(.system(size: 36, weight: .bold))
                        .padding()

                    HStack {
                        Button("−5") {
                            scrubberData.state.step(by: -5)
                        }
                        .disabled(scrubberData.state.isAtMinimum)

                        Button("+5") {
                            scrubberData.state.step(by: 5)
                        }
                        .disabled(scrubberData.state.isAtMaximum)
                    }
                    .buttonStyle(.bordered)
                }
            }

            Spacer()
        }
        .padding()
    }
}

struct ScrubberScrollBehavior: ScrollTargetBehavior {
    func updateTarget(_ target: inout ScrollTarget, context: TargetContext) {
        let tickSpacing: CGFloat = 8.0 + 2.0
        let paddingOffset: CGFloat = 130

        let scrollOffset = target.rect.minY + paddingOffset
        let tickIndex = round(scrollOffset / tickSpacing)
        let clampedIndex = max(0, tickIndex)

        let targetPosition = (clampedIndex * tickSpacing) - paddingOffset

        target.rect.origin.y = targetPosition
    }
}

struct VerticalScrubber: View {
    @Bindable var scrubberData: ScrubberData

    var body: some View {
        ScrollView(.vertical, showsIndicators: false) {
            VStack(spacing: scrubberData.config.spacing) {
                Spacer(minLength: 130)
                ForEach(scrubberData.allTicks) { tick in
                    tickMarkView(for: tick)
                }
                Spacer(minLength: 130)
            }
            .scrollTargetLayout()
        }
        .scrollTargetBehavior(ScrubberScrollBehavior())
        .defaultScrollAnchor(.center)
        .scrollPosition(id: $scrubberData.scrollPosition, anchor: .center)
        .frame(width: 100)
        .task {
            scrubberData.scrollPosition = scrubberData.state.currentTickIndex
        }
        .onChange(of: scrubberData.state.currentTickIndex) { _, newIndex in
            if scrubberData.scrollPosition != newIndex {
                withAnimation(.easeInOut) {
                    scrubberData.scrollPosition = newIndex
                }
            }
        }
    }

    private func tickMarkView(for tick: TickInfo) -> some View {
        let isSelected = tick.index == scrubberData.state.currentTickIndex

        return HStack(spacing: 8) {
            Rectangle()
                .fill(tickColor(for: tick, isSelected: isSelected))
                .frame(
                    width: tickWidth(for: tick, isSelected: isSelected),
                    height: tickHeight(for: tick, isSelected: isSelected)
                )
                .shadow(
                    color: isSelected ? Color.accentColor.opacity(0.4) : Color.clear,
                    radius: isSelected ? 4 : 0,
                    x: 0,
                    y: 0
                )
                .animation(.easeInOut(duration: 0.2), value: isSelected)

            if let label = tick.label {
                Text(label)
                    .font(.system(
                        size: isSelected ? 14 : 12,
                        weight: isSelected ? .bold : .medium
                    ))
                    .foregroundColor(isSelected ? .accentColor : .primary)
                    .fixedSize()
                    .animation(.easeInOut(duration: 0.2), value: isSelected)
            }
        }
        .frame(maxWidth: .infinity, alignment: .trailing)
        .padding(.trailing, 8)
        .background(
            RoundedRectangle(cornerRadius: 4)
                .fill(isSelected ? Color.accentColor.opacity(0.1) : Color.clear)
                .animation(.easeInOut(duration: 0.2), value: isSelected)
        )
    }

    private func tickColor(for tick: TickInfo, isSelected: Bool) -> Color {
        if isSelected {
            return .accentColor
        } else if tick.isMajor {
            return .accentColor.opacity(0.7)
        } else {
            return .gray.opacity(0.5)
        }
    }

    private func tickWidth(for tick: TickInfo, isSelected: Bool) -> CGFloat {
        if isSelected {
            return tick.isMajor ? 32 : 20
        } else {
            return tick.isMajor ? 24 : 12
        }
    }

    private func tickHeight(for tick: TickInfo, isSelected: Bool) -> CGFloat {
        if isSelected {
            return tick.isMajor ? 3 : 2
        } else {
            return tick.isMajor ? 2 : 1
        }
    }
}


SwiftUI scroll position targeting buggy with viewAligned scrollTargetBehavior
 
 
Q