0

I have the following scrubber implementation in SwiftUI. The+ button is supposed to move theScrollView up by 1 tick (orscrollPosition 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 useScrollViewReader as a workaround, it starts scrolling after 2 '+' button taps.

import SwiftUIstruct 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()}
askedApr 5 at 15:42
Deepak Sharma's user avatar
5
  • Try using a LazyVStack first.CommentedApr 5 at 18:11
  • Whats the issue withScrollView? This question is not aboutLazyVStack.CommentedApr 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.CommentedApr 5 at 21:49
  • See the answer, it will help youCommentedApr 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.CommentedApr 6 at 22:08

2 Answers2

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.
answeredApr 5 at 19:13
Marcel Derks's user avatar
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. Theanchor 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 forscrollPosition 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 theText, but in reality it's stillnil.

So if you needed to actually scroll to the 5th tick initially by setting thescrollPosition 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 theScrollView.

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 theVStack 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 SwiftUIstruct 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 fromVStack and instead adding margins to theScrollView, accounting for the height of each individualtick, 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 SwiftUIstruct 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 theScrollView 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

answeredApr 6 at 22:08
Andrei G.'s user avatar

Comments

Your Answer

Sign up orlog in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

By clicking “Post Your Answer”, you agree to ourterms of service and acknowledge you have read ourprivacy 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.