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?This problem seems to be related to the horizontal padding on the HStack
. It works without the padding (but obviously, only for positions that don't need it to be there).
My guess is that ViewAlignedScrollTargetBehavior
is a bit broken. As a workaround, you can try implementing your own ScrollTargetBehavior
.
I had a go at this and discovered that the function updateTarget
is called in different ways, depending on whether it is the first show or in response to a scroll gesture:
When called for first show, the target has an anchor of .center
and the target width is the width of a circle. The x-offset of the target origin incorporates half the screen width.
Interestingly, no correction is needed for first show. So this appears to be where ViewAlignedScrollTargetBehavior
is going wrong.
When subsequently called for scroll gestures, the target anchor is nil and the target width is the width of the container (the ScrollView
).
A nil anchor appears to be interpreted as .topLeading
. So in this case, the x-offset of the target relates to the leading edge of the ScrollView
, not the center.
I tried updating the target anchor to .center
and adjusting the target width, but couldn't get it to work with this approach (it always scrolled too much). It seems best to leave the target anchor and width unchanged and accept that it relates to the leading edge.
Here is the custom behavior, which is specific to your layout:
struct StickyCentrePosition: ScrollTargetBehavior {
let itemWidth: CGFloat
let spacing: CGFloat
let sidePadding: CGFloat
func updateTarget(_ target: inout ScrollTarget, context: TargetContext) {
// dx is the distance from the target anchor to the
// leading edge of a centered item
let dx = (target.anchor?.x ?? 0) == 0
? (context.containerSize.width / 2) - (itemWidth / 2)
: 0
let currentTargetIndex = (target.rect.origin.x + dx - sidePadding) / (itemWidth + spacing)
let roundedTargetIndex = currentTargetIndex.rounded()
let scrollCorrection = (roundedTargetIndex - currentTargetIndex) * (itemWidth + spacing)
target.rect.origin.x += scrollCorrection
}
}
Since you a using the state variable currentIndex
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 {
HStack(spacing: 0) {
Color.gray.opacity(0.2)
Color.clear
}
.ignoresSafeArea()
GeometryReader { geometry in
let screenWidth = geometry.size.width
let totalSpacing = spacing * CGFloat(visibleItems - 1)
let circleSize = (screenWidth - totalSpacing) / CGFloat(visibleItems)
let sidePadding = (screenWidth - circleSize) / 2
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, sidePadding)
}
.scrollIndicators(.never)
.scrollTargetBehavior(
StickyCentrePosition(
itemWidth: circleSize,
spacing: spacing,
sidePadding: sidePadding
)
)
.scrollPosition(id: $currentIndex, anchor: .center)
.onAppear { currentIndex = initialPosition }
}
}
}
}