0

I have the following scrubber implementation in SwiftUI. The + button is supposed to move the ScrollView up by 1 tick (or scrollPosition incremented by 1) but the issue is no scrolling happens until I click 8-9 times. Is this a bug in iOS or a programming error?


struct BrokenVerticalScrubberDemo: View {
    @State private var scrollPosition: Int? = 0
    @State private var count: Int = 20

    var body: some View {
        VStack {
            Text("Scroll Position: \(scrollPosition ?? 0)")
                .font(.headline)

            ScrollView(.vertical, showsIndicators: true) {
                VStack(spacing: 8) {
                    ForEach(0..<count, id: \.self) { index in
                        Text("Tick \(index)")
                            .frame(height: 30)
                            .id(index)
                    }
                }
                .scrollTargetLayout()
                .padding(.vertical, 50)
            }
            .scrollTargetBehavior(.viewAligned)
            .scrollPosition(id: $scrollPosition)
            .frame(height: 300)
            .border(Color.gray)

            Button("+1") {
                withAnimation {
                    scrollPosition = min((scrollPosition ?? 0) + 1, count - 1)
                }
            }
            .padding(.top)
        }
        .padding()
    }
}

#Preview {
    BrokenVerticalScrubberDemo()
}

In contrast, if I use ScrollViewReader as a workaround, it starts scrolling after 2 '+' button taps.

import SwiftUI

struct SomeWhatWorkingVerticalScrubberDemo: View {
    @State private var scrollPosition: Int = 0
    @State private var count: Int = 20

    var body: some View {
        VStack {
            Text("Scroll Position: \(scrollPosition)")
                .font(.headline)

            ScrollView(.vertical, showsIndicators: true) {
                ScrollViewReader { scrollViewProxy in
                    VStack(spacing: 8) {
                        ForEach(0..<count, id: \.self) { index in
                            Text("Tick \(index)")
                                .frame(height: 30)
                                .id(index)
                        }
                    }
                    .padding(.vertical, 50)
                    .onChange(of: scrollPosition) { newPosition in
                        withAnimation {
                            scrollViewProxy.scrollTo(newPosition, anchor: .center)
                        }
                    }
                }
            }
            .frame(height: 300)
            .border(Color.gray)

            Button("+1") {
                scrollPosition = min(scrollPosition + 1, count - 1)
            }
            .padding(.top)
        }
        .padding()
    }
}

#Preview {
    SomeWhatWorkingVerticalScrubberDemo()
}

5
  • Try using a LazyVStack first. Commented Apr 5 at 18:11
  • Whats the issue with ScrollView? This question is not about LazyVStack. Commented Apr 5 at 18:31
  • .scrollTargetLayout works best with a lazy stack and depending on the layout not even align/snap sometimes with a regular stack. This is why I suggested it. Commented Apr 5 at 21:49
  • See the answer, it will help you Commented Apr 6 at 10:52
  • I saw the answer and it didn't really help me, so I added an answer myself which you should see. It will help you. Commented Apr 6 at 22:08

2 Answers 2

2

Please try to use top anchor

.scrollPosition(id: $scrollPosition, anchor: .top)

on the scroll position.

The documentation about the default anchor behaviour is a bit cryptic to me. But in your example it seems to be the bottom anchor.

 /// If no anchor has been provided, SwiftUI will scroll the minimal amount
 /// when using the scroll position to programmatically scroll to a view.
Sign up to request clarification or add additional context in comments.

1 Comment

Good suggestion, but it doesn't really address the other bigger issues. I added an answer that explains them. Agreed that the documentation is quite cryptic on this. The anchor parameter dictates which edge of the scrollview will be used to observe how much the respective scrolled element intersects with that edge, which will result in an update of the scrollPosition. So for a .bottom anchor, if more than half the height of the element intersects with the bottom edge of the scrollview, it will be evaluated as visible and the scrollPosition id will be updated to reflect the id of that view.
1

Adding an anchor may fix your button scrolling, but doesn't really fix the flaw in the layout and overall use of the scrubber, since you will never be able to select the entire range of the values (like 0, or anything above 13).

Also, if dragging the scrubber (not using the button), the values don't align properly - they either advance two values at a time or require dragging over two steps to increase it by one step, as shown in the recording below.

enter image description here

There is also the issue that setting a default value for scrollPosition doesn't actually work like that. In your example, you set it to zero and you're showing it as zero when unwrapping it in the Text, but in reality it's still nil.

So if you needed to actually scroll to the 5th tick initially by setting the scrollPosition default value to 5, it wouldn't work. To actually make it work, leave it with no default value and set an initial value in .onAppear of the ScrollView.

The view alignment (if correctly implemented) should work with any anchor (or even no anchor). In this case, any anchor other than .top will basically not work (and even .top has the issues described above).

The culprit for all this is the vertical padding adding to the VStack which is used for the layout target:

.padding(.vertical, 50) // <- breaks everything

Here's a complete code that reproduces the issue, with options for selecting different anchors:

enter image description here

import SwiftUI

struct DamagedVerticalScrubberDemo: View {
    
    //State values
    @State private var scrollPosition: Int? = 0
    @State private var count: Int = 20
    @State private var selectedAnchor: UnitPoint?
    
    //Body
    var body: some View {
        let step = 1
        let range = 0...count
        
        VStack {
            Text("Scroll Position: \(scrollPosition ?? 0)")
                .font(.headline)
            
            ScrollView(.vertical, showsIndicators: false) {
                VStack(spacing: 8) {
                    ForEach(0..<count, id: \.self) { index in
                        Text("Tick \(index)")
                            .frame(height: 30)
                            // .border(.red)
                            .id(index)
                    }
                }
                // .border(.green)
                .padding(.vertical, 50)
                .scrollTargetLayout()
            }
            .scrollTargetBehavior(.viewAligned)
            .scrollPosition(id: $scrollPosition, anchor: selectedAnchor)
            .frame(height: 300)
            .frame(maxWidth: .infinity, alignment: .center)
            .overlay {
                Divider()
            }
            .border(Color.gray)
            
            VStack {
                Stepper(
                    value: Binding(
                        get: { scrollPosition ?? 0 },
                        set: { newValue in
                            withAnimation {
                                scrollPosition = newValue
                            }
                        }),
                    in: range,
                    step: step
                ){}
                    .fixedSize()
                    .padding(.top)
                
                Text("Adjust scroll position")
                    .font(.caption2)
                    .foregroundStyle(.secondary)
                
                Picker("", selection: $selectedAnchor) {
                    Text("Select anchor").tag(nil as UnitPoint?)
                    Label("Top anchor", systemImage: "inset.filled.topthird.rectangle").tag(UnitPoint.top)
                    Label("Center anchor", systemImage: "inset.filled.center.rectangle").tag(UnitPoint.center)
                    Label("Bottom anchor", systemImage: "inset.filled.bottomthird.rectangle").tag(UnitPoint.bottom)
                }
                .padding(.top)
            }
        }
        .padding()
    }
}

#Preview {
    DamagedVerticalScrubberDemo()
}

The solution:

The fix is rather simple and consists in removing any padding from VStack and instead adding margins to the ScrollView, accounting for the height of each individual tick, in order to vertically center everything and allow selection of the complete range of values. So in this case, it would be:

ScrollView {
    //content...
}
.contentMargins(.vertical, (300 - 30) / 2)

enter image description here

Here's the complete working code:

import SwiftUI


struct FixedVerticalScrubberDemo: View {
    
    //Values
    let step = 1
    let range = 1...20
    let scrollViewHeight: CGFloat = 300
    let tickHeight: CGFloat = 30

    //State values
    @State private var scrollPosition: Int?
    @State private var showInfoOverlay = false
    
    //Computed values
    private var margins: CGFloat {
        (scrollViewHeight - tickHeight) / 2
    }
    
    var body: some View {
        VStack {
            Text("Scroll Position: \(scrollPosition ?? 0)")
                .font(.headline)
            
            ScrollView(.vertical, showsIndicators: false) {
                VStack(spacing: 8) {
                    ForEach(range, id: \.self) { index in
                        Text("Tick \(index)")
                            .padding()
                            .frame(height: tickHeight)
                            .scrollTransition(.animated) { content, phase in
                                content
                                    .opacity(phase.isIdentity ? 1 : 0.3)
                                    .blur(radius: phase.isIdentity ? 0 : 0.5)
                                    .scaleEffect(phase.isIdentity ? 1.2 : 1)
                            }
                            .id(index)
                    }
                }
                .scrollTargetLayout()
            }
            .contentMargins(.vertical, margins)
            .scrollTargetBehavior(.viewAligned)
            .scrollPosition(id: $scrollPosition)
            .frame(height: scrollViewHeight)
            .frame(maxWidth: .infinity, alignment: .center)
            .background {
                Divider()
                    .background(.gray)
                    .overlay{
                        Capsule()
                            .fill(Color(.systemBackground))
                            .stroke(.gray.opacity(0.5))
                            .frame(width: 100, height: tickHeight)
                    }
            }
            .overlay {
                if showInfoOverlay {
                    infoOverlay
                }
            }
            .onAppear { // <- set default scrollPosition value here
                scrollPosition = 1
            }
            .border(Color.gray)
            .animation(.smooth, value: scrollPosition)
            
            //Stepper
            HStack {
                
                Button("First") {
                    scrollPosition = range.lowerBound
                }
                .disabled(scrollPosition == range.lowerBound)
                
                Stepper(
                    value: Binding(
                        get: { scrollPosition ?? 0 },  // If the optional is nil, default to 0
                        set: { newValue in
                            withAnimation {
                                scrollPosition = newValue  // Apply animation when value changes
                            }
                        }),
                    in: range,
                    step: step
                ) {}
                    .labelsHidden()
                    .fixedSize()
                    .padding()
                
                Button("Last") {
                    scrollPosition = range.upperBound
                }
                .disabled(scrollPosition == range.upperBound)
            }
            .buttonStyle(.bordered)
            .buttonBorderShape(.roundedRectangle(radius: 12))
            
            //Overlay toggle
            Toggle("Show info overlay", isOn: $showInfoOverlay)
                .padding()
        }
        .padding()
    }
    
    private var infoOverlay: some View {
        VStack(spacing: 0) {
            Color.indigo
                .frame(height: margins)
                .overlay {
                    Text("Top margins/padding")
                }
                .opacity(0.5)
            
            Color.yellow.opacity(0.1)
                .frame(height: tickHeight)
            
            Color.indigo
                .frame(height: margins)
                .overlay {
                    Text("Bottom margins/padding")
                }
                .opacity(0.5)
        }
    }
}

#Preview("Explanation") {
    FixedVerticalScrubberDemo()
}

Note that an anchor wasn't used in the code above, because it's not really required in this use case, where tick marks are centered in the scrollview.

To recap:

  • Use .contentMargins on the ScrollView to adjust padding based on scrollview dimensions and the individual view size.

  • Set a default scroll position value in .onAppear of the scroll view.

enter image description here

Comments

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.