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 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:

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)

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:

ScrollView? This question is not aboutLazyVStack.