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

SwiftUI scroll position targeting buggy with viewAligned scrollTargetBehavior
 
 
Q