I'm building a horizontal ScrollView
in SwiftUI that snaps items to the centre of the screen. The snapping works perfectly when scrolling, but when the view first loads, the initial item is offset slightly and doesn't snap to the center as expected.
I've identified that the issue occurs when the number of visibleItems
is even. With an odd number of visibleItems
, the initial alignment and snapping work correctly right from the start.
Here's the code for my CircleScrollView
:
struct CircleScrollView: View {
@State(initialValue: 2)
var initialPosition: Int
@State(initialValue: 8)
private var visibleItems: Int
@State(initialValue: 0)
private var currentIndex: Int
private let spacing: CGFloat = 16
var body: some View {
ZStack(alignment: .leading) {
// For visuals of screen centre
Rectangle()
.fill(Color.gray.opacity(0.2))
.ignoresSafeArea()
.frame(maxWidth: UIScreen.main.bounds.width / 2, maxHeight: .infinity, alignment: .leading)
GeometryReader { geometry in
let totalSpacing = spacing * CGFloat(visibleItems - 1)
let circleSize = (geometry.size.width - totalSpacing) / CGFloat(visibleItems)
ScrollViewReader { scrollViewProxy in
ScrollView(.horizontal) {
HStack(spacing: spacing) {
ForEach(1..<100) { index in
ZStack {
Text("\(index)")
Circle().fill(Color(.tertiarySystemFill))
}
.frame(width: circleSize, height: circleSize)
.id(index)
}
}
.scrollTargetLayout()
.padding(.horizontal, (geometry.size.width - circleSize) / 2)
.onAppear {
scrollViewProxy.scrollTo(initialPosition, anchor: .center)
currentIndex = initialPosition
}
}
.scrollIndicators(.never)
.scrollTargetBehavior(.viewAligned)
}
}
}
}
}
Issue:
visibleItems
is even.visibleItems
is odd.Steps Taken:
circleSize
and spacing.Question:
visibleItems
is even?The problem is being caused by the horizontal padding on the HStack
. In particular, the padding is part of the scrolled content, so this is causing problems for ViewAlignedScrollTargetBehavior
.
The way to fix is to remove the padding from the HStack
and set .contentMargins
on the ScrollView
instead.
Also, since currentIndex
is being to record the selected position, it works well to use this as the .scrollPosition
for the ScrollView
. This way it is updated as scrolling happens and a ScrollViewReader
is not needed. The variable just needs to be changed to an optional for this to work.
Here is the fully updated example, which now works for both an even and odd number of visible items:
struct CircleScrollView: View {
let initialPosition = 2
let visibleItems = 8
let spacing: CGFloat = 16
@State private var currentIndex: Int?
var body: some View {
ZStack(alignment: .leading) {
// For visuals of screen centre
HStack(spacing: 0) {
Color.gray.opacity(0.2)
Color.clear
}
.ignoresSafeArea()
GeometryReader { geometry in
let totalSpacing = spacing * CGFloat(visibleItems - 1)
let circleSize = (geometry.size.width - totalSpacing) / CGFloat(visibleItems)
ScrollView(.horizontal) {
HStack(spacing: spacing) {
ForEach(1..<100) { index in
ZStack {
Text("\(index)")
Circle().fill(Color(.tertiarySystemFill))
}
.frame(width: circleSize, height: circleSize)
.id(index)
}
}
.scrollTargetLayout()
}
.contentMargins(.horizontal, (geometry.size.width - circleSize) / 2)
.scrollIndicators(.never)
.scrollTargetBehavior(.viewAligned)
.scrollPosition(id: $currentIndex)
.onAppear {
currentIndex = initialPosition
}
}
}
}
}