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()}- Whats the issue with
ScrollView? This question is not aboutLazyVStack.Deepak Sharma– Deepak Sharma2025-04-05 18:31:32 +00:00CommentedApr 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.Andrei G.– Andrei G.2025-04-05 21:49:11 +00:00CommentedApr 5 at 21:49
- See the answer, it will help youDeepak Sharma– Deepak Sharma2025-04-06 10:52:44 +00:00CommentedApr 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.Andrei G.– Andrei G.2025-04-06 22:08:56 +00:00CommentedApr 6 at 22:08
2 Answers2
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.1 Comment
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.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.
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 everythingHere's a complete code that reproduces the issue, with options for selecting different anchors:
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)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
.contentMarginson theScrollViewto adjust padding based on scrollview dimensions and the individual view size.Set a default scroll position value in
.onAppearof the scroll view.
Comments
Explore related questions
See similar questions with these tags.




