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:

Steps Taken:

Question:


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:

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