1

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

enter image description here

4
  • 1
    id: \.self in ForEach instead of real identifier keypath and .onChange instead of Binding are likely contributing factors to the issue. Also DispatchQueue.main.async inside structs that have no lifetime is also asking for trouble. Commented Jul 2 at 6:59
  • Nothing resolves it, whether replacing id with anything else or removing DispatchAsync main which is only called onAppear the very first time. Commented Jul 2 at 10:09
  • Have you tried not using GeometryReader? Commented Jul 2 at 10:15
  • @LawrenceGimenez How do you calculate padding without GeometryReader? Please feel free to post your answer without GeometryReader. Commented Jul 2 at 10:37

1 Answer 1

1

The key change that is needed to get this working is to remove the .safeAreaPadding from the VStack and apply vertical .contentMargins to the ScrollView instead.

To get it working precisely, it also helps to use the same fixed height for all tick marks. Then, calculate the vertical padding as: (geometry.size.height - tickHeight) / 2.

let tickHeight: CGFloat = 8 // 👈 added
GeometryReader { geometry in
    let verticalPadding = (geometry.size.height - tickHeight) / 2 // 👈 changed

    ZStack(alignment: .trailing) {
        ScrollView(.vertical, showsIndicators: false) {
            VStack(spacing: config.spacing) {
                ForEach(0...(config.steps * config.count), id: \.self) { index in
                    horizontalTickMark(for: index)
                        .frame(height: tickHeight) // 👈 added
                        .id(index)
                }
            }
            .frame(width: 80)
            .scrollTargetLayout()
            // .safeAreaPadding(.vertical, verticalPadding) // 👈 removed
        }
        .scrollTargetBehavior(.viewAligned)
        .scrollPosition(id: $scrollPosition, anchor: .top)
        .contentMargins(.vertical, verticalPadding) // 👈 added

        // + Capsule, as before
    }
    // + modifiers, as before
}

Animation

Sign up to request clarification or add additional context in comments.

4 Comments

It works, but I am wondering if there is a need to have fixed height for the tickmark.
I am not sure if .viewAligned scroll target behavior will work reliably if the views (the tick rows) have different heights. When I tried it on an iPhone 16 simulator running iOS 26.0, it was not reliable and it did not stop on the tick marks precisely. In any case, the exact height of the major ticks needs to be known in order to calculate the vertical padding. Ps. Thanks for accepting the answer.
You are right there is an issue with .viewAligned scroll target behaviour. Programatic scrolling is not reliable, atleast on iOS 26. I will post a new question soon.
Here is the link to new question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.