0

The following code fails to update scrollPosition in SwiftUI ScrollView when the user auto-scrolls, i.e. swipes the finger to scroll and picks up the finger immediately. While the scroll view continues scrolling till it decelerates and stops as the finger has been lifted, the scrollPosition is not seen updated in this time. I have not been able to figure out why it is happening.

import SwiftUI
import UIKit

struct ScrubberConfig2 {
    var count:Int
    var majorTickInterval:Int
    var spacing:CGFloat
    let labelFormatter: ((Int) -> String)?
    
    init(count: Int, majorTickInterval: Int, spacing: CGFloat, labelFormatter: ((Int) -> String)? = nil) {
        self.count = count
        self.majorTickInterval = majorTickInterval
        self.spacing = spacing
        self.labelFormatter = labelFormatter
    }
}

struct VerticalScrubber2: View {
    var config: ScrubberConfig2
    @Binding var value: Int
    
    let tickHeight: CGFloat = 5 // 👈 added
    
    @State private var isUserInteracting: Bool = false // New state to track user interaction
    
    @State private var isScrollPositionSet = false // 👈 added
    @State private var scrollPosition:Int?
    
    var body: some View {
        GeometryReader { geometry in
            let verticalPadding = (geometry.size.height - tickHeight) / 2
            
            ZStack(alignment: .trailing) {
                ScrollView(.vertical, showsIndicators: false) {
                    VStack(spacing: config.spacing) {
                        ForEach(0...config.count, id: \.self) { index in
                            horizontalTickMark(for: index)
                                .frame(height: tickHeight)
                                .id(index)
                        }
                    }
                    .frame(width: 80)
                    .scrollTargetLayout()
                }
                .defaultScrollAnchor(.center)
                .scrollTargetBehavior(.viewAligned)
                .scrollPosition(id: $scrollPosition, anchor: .center)
                .contentMargins(.vertical, verticalPadding)
        
                Capsule()
                    .frame(width: 32, height: 3)
                    .foregroundColor(.accentColor)
                    .shadow(color: .accentColor.opacity(0.3), radius: 3, x: 0, y: 1)
            }
            .frame(width: 100)
            .onAppear { 
                scrollPosition = value
            }
            .onChange(of: value, { oldValue, newValue in
                if scrollPosition != newValue, !isUserInteracting {
                    print("📥 Value changed: \(oldValue) → \(newValue)")
                    scrollPosition = newValue
                }
            })
            .onChange(of: scrollPosition, initial: true, { oldValue, newValue in
                if let newValue = newValue, value != newValue, isUserInteracting {
                    print("👆 ScrollPosition changed: \(oldValue ?? -1) → \(newValue)")
                    value = newValue
                }
            })
            .onScrollPhaseChange { oldPhase, newPhase in
                if newPhase == .interacting {
                    isUserInteracting = true
                } else {
                    isUserInteracting = false
                }
            }
        }
    }
    
    private func horizontalTickMark(for index: Int) -> some View {
           let isMajorTick = index % config.majorTickInterval == 0
           
           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(labelText(for: index))
                       .font(.system(size: 12, weight: .medium))
                       .foregroundColor(.primary)
                       .fixedSize()
               }
           }
           .frame(maxWidth: .infinity, alignment: .trailing)
           .padding(.trailing, 8)
       }
       
       private func labelText(for index: Int) -> String {
           if let formatter = config.labelFormatter {
               return formatter(index)
           } else {
               // Default: show index / steps * 5
               let tickValue = index
               return "\(tickValue)"
           }
       }
}

struct VerticalScrubberPreview2: View {
    @State private var value: Int = 0
    private let config = ScrubberConfig2(count: 100, majorTickInterval: 5, spacing: 5)
    
    var body: some View {
        Text("Vertical Scrubber (0–100 in steps of 5)")
            .font(.title2)
            .padding()
        
        HStack(spacing: 30) {
            VerticalScrubber2(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)")
                    .font(.system(size: 36, weight: .bold))
                    .padding()
            }
            
            Spacer()
        }
        .padding()
    }
}

#Preview {
    VerticalScrubberPreview2()
}

5
  • This question appears to be a follow-up to your previous question. You accepted my answer to that question, which showed how to replace the state variable for scroll position with a computed binding. This question has gone back to using a separate state variable for the scroll position, with dual .onChange handlers to propagate changes between the scrollPosition and value. Why not start with the solution you already accepted and then focus on new issues that have not already been addressed? Commented Nov 3 at 20:14
  • @BenzyNeez Using computed variable was giving other issues in the setup. Your answer itself indicated that one option was to use an Int instead of CGFloat which I now tried. It looks like this scrubber always gives some issue or the other. The point of this question is why "autoscroll" is not updating scrollPosition? Commented Nov 3 at 20:59
  • ` if newPhase == .interacting || newPhase == .decelerating {...}` Is this what you mean? Commented Nov 4 at 2:37
  • @Tim yes, I think there is a mistake in setting isUserInteracting by not considering all cases for newPhase. Commented Nov 4 at 8:57
  • Cheers buck-a-roo Commented Nov 4 at 14:44

1 Answer 1

1

This question appears to be a follow-up to your previous question:

SwiftUI programmatic scrolling misses on frequent updates to value

The differences that I have noticed:

  1. You are not using the changes from the answer you accepted (it was my answer).
  2. The variables scrollPosition and value are now both Int and they both represent the same value, there is no factor of 5 involved any more.
  3. You are using the state variable isUserInteracting to detect when the user is interacting with the ScrollView.

It seems to me that the variable isUserInteracting is causing the issue you are reporting and I don't really understand why you think it is needed. When I adapt the previous solution to the changes described under point 2, it works for me.

The changes are essentially exactly the same as before:

struct VerticalScrubber2: View {
    var config: ScrubberConfig2
    @Binding var value: Int
    let tickHeight: CGFloat = 5
    // @State private var isUserInteracting: Bool = false // 👈 not needed
    @State private var isScrollPositionSet = false

    private var scrollPosition: Binding<Int?> { // 👈 replaces state variable
        Binding<Int?>(
            get: {
                isScrollPositionSet ? value : nil
            },
            set: { newValue in
                if let newValue {
                    value = newValue
                    isScrollPositionSet = true
                } else {
                    isScrollPositionSet = false
                }
            }
        )
    }

    var body: some View {
        GeometryReader { geometry in
            let verticalPadding = (geometry.size.height - tickHeight) / 2

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

                Capsule()
                    .frame(width: 32, height: 3)
                    .foregroundColor(.accentColor)
                    .shadow(color: .accentColor.opacity(0.3), radius: 3, x: 0, y: 1)
            }
            .frame(width: 100)
            .onAppear {
                scrollPosition.wrappedValue = value // 👈 updated
            }
            // 👉 .onChange and .onScrollPhaseChange modifiers removed
        }
    }

    private func horizontalTickMark(for index: Int) -> some View {
        // unchanged
    }
    
    private func labelText(for index: Int) -> String {
        // unchanged
    }
}

Animation

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

3 Comments

I need two variables value and scrollPosition as I can not use value in place of scrollPosition as one is Binding while other is State.
> I can not use value in place of scrollPosition as one is Binding while other is State - the code in the answer demonstrates how it can in fact be done, using a computed binding.
I respect that, but I think I have achieved what I wanted by removing isUserInteracting...so far it works like a charm. Thanks for answering as always.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.