Search code examples
iosswiftswiftuigeometryreaderscrolltargetbehavior

SwiftUI Horizontal ScrollView Snapping Issue on Initial Load When `visibleItems` is Even


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:

  • The initial scroll position is offset when visibleItems is even.
  • Works correctly when scrolling or when visibleItems is odd.

Steps Taken:

  • Checked the calculation of circleSize and spacing.
  • Verified the initial alignment by adjusting the padding.

Question:

  • How can I ensure the initial item aligns correctly when visibleItems is even?
  • Is there a better way to handle the alignment or snapping during the initial load?

Example simulator loading incorrectly snapped, but on scroll working correctly


Solution

  • 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 }
                }
            }
        }
    }