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

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