iosswiftswiftui

How to support onScrollPhaseChange for iOS 17 using SwiftUI?


I have created Horizontal Picker View with following functionalities:

But... there is one issue:

I need to support iOS 17 also. And there is now working. Trying solution with simultanous gestures failed. Do you have any ideas?

Here is current working example. Just copy and paste.

import SwiftUI

struct SnapPickerView: View {
    private var elements = ["Sirene 187", "FC Clubbing completely new", "John", "Malkovich", "Santander stret 126 hood", "5", "6", "7", "5", "6", "7", "5", "6", "7", "5", "6", "7", "5", "6", "7", "5", "6", "7", "5", "6", "7", "5", "6", "7", "5", "6", "7", "5", "6", "7", "FC Clubbing completely new 23"]
    
    @State private var selectedIndex = 10
    @State private var itemsPositions: [Int: CGRect] = [:]
    @State private var containerWidth: CGFloat = 0
    
    var body: some View {
        GeometryReader { geometry in
            let centerX = geometry.size.width / 2
            VStack {
                ScrollViewReader { proxy in
                    if #available(iOS 18.0, *) {
                        ScrollView(.horizontal, showsIndicators: false) {
                            ZStack(alignment: .leading) {
                                // Use this to track scroll position
                                GeometryReader { scrollGeo in
                                    Color.clear
                                        .preference(key: ScrollOffsetKey.self, value: scrollGeo.frame(in: .named("scrollView")).minX)
                                }
                                .frame(width: 0, height: 0)
                                
                                HStack(spacing: 16) {
                                    Spacer()
                                        .frame(width: centerX)
                                    ForEach(0..<elements.count, id: \.self) { index in
                                        Text(elements[index])
                                            .padding()
                                            .background(
                                                RoundedRectangle(cornerRadius: 10)
                                                    .fill(selectedIndex == index ? Color.blue.opacity(0.2) : Color.gray.opacity(0.1))
                                            )
                                            .id(index)
                                            .onTapGesture {
                                                withAnimation {
                                                    selectedIndex = index
                                                    proxy.scrollTo(index, anchor: .center)
                                                }
                                            }
                                            .background(
                                                GeometryReader { itemGeometry in
                                                    Color.clear
                                                        .preference(
                                                            key: ItemPositionKey.self,
                                                            value: [ItemPosition(
                                                                index: index,
                                                                position: itemGeometry.frame(in: .named("scrollView"))
                                                            )]
                                                        )
                                                }
                                            )
                                    }
                                    Spacer()
                                        .frame(width: centerX)
                                }
                            }
                        }
                        .coordinateSpace(name: "scrollView")
                        .onPreferenceChange(ItemPositionKey.self) { positions in
                            // Process the positions and store them
                            positions.forEach { pos in
                                itemsPositions[pos.index] = pos.position
                            }
                        }
                        .onAppear {
                            containerWidth = geometry.size.width
                            
                            // Initial selection
                            DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                                withAnimation {
                                    proxy.scrollTo(selectedIndex, anchor: .center)
                                }
                            }
                        }
                        .onChange(of: geometry.size.width) { _, newWidth in
                            containerWidth = newWidth
                            
                            // Recenter on container size change
                            withAnimation {
                                proxy.scrollTo(selectedIndex, anchor: .center)
                            }
                        }
                        // Ensure selection is centered when it changes
                        .onChange(of: selectedIndex) { _, _ in
                            withAnimation {
                                proxy.scrollTo(selectedIndex, anchor: .center)
                            }
                        }
                        .onScrollPhaseChange { _, newPhase in
                            if newPhase == .idle {
                                // Scrolling has completely stopped
                                print("Scrolling stopped. Finding nearest item...")
                                selectNearestItem(proxy: proxy)
                            }
                        }
                    } else {
                    }
                }
            }
            .frame(height: 70)
        }
    }
    
    // Helper method to select the item nearest to the center
    private func selectNearestItem(proxy: ScrollViewProxy) {
        guard !itemsPositions.isEmpty else { return }
        
        let center = containerWidth / 2
        
        // Find the item whose center is closest to the container's center
        var closestIndex = 0
        var smallestDistance: CGFloat = .infinity
        
        for (index, frame) in itemsPositions {
            let itemCenter = frame.midX
            let distance = abs(itemCenter - center)
            
            if distance < smallestDistance {
                smallestDistance = distance
                closestIndex = index
            }
        }
        
        // Debug print
        print("Selecting item at index: \(closestIndex)")
        
        // Only update if different from current selection
        if selectedIndex != closestIndex {
            withAnimation {
                selectedIndex = closestIndex
                proxy.scrollTo(closestIndex, anchor: .center)
            }
        }
    }
}

// Preference key for tracking scroll offset
struct ScrollOffsetKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}

// Structure for tracking item positions
struct ItemPosition: Equatable {
    let index: Int
    let position: CGRect
}

// Preference key for tracking item positions
struct ItemPositionKey: PreferenceKey {
    static var defaultValue: [ItemPosition] = []
    static func reduce(value: inout [ItemPosition], nextValue: () -> [ItemPosition]) {
        value.append(contentsOf: nextValue())
    }
}

#Preview {
    SnapPickerView()
        .padding()
}

enter image description here enter image description here


Solution

  • You might not need .onScrollPhaseChange if you use .scrollTargetBehavior instead (available in iOS 17).

    If the items all had the same widths then you could just use ViewAlignedScrollTargetBehavior. But since the items have irregular widths, a custom ScrollTargetBehavior can be used to snap to the selected target.

    The answer to Center View align SwiftUI ScrollView provides a custom ScrollTargetBehavior that works by tracking the selected item using .scrollPosition, then using the midX offset of this item as the target for scrolling (it was my answer). This works well for a slow scroll, but sometimes snaps in the wrong direction for an inertia scroll.

    An improvement can be made on that implementation by recording the positions of all the elements in an array. Then, when an inertia scroll is detected, the scroll target can be set to the element that is closest to the predicted position:

    struct MidPositionAligned: ScrollTargetBehavior {
        let selectedIndex: Int?
        let elementPositions: [CGRect]
    
        func updateTarget(_ target: inout ScrollTarget, context: TargetContext) {
    
            // Only perform an adjustment if the target anchor is
            // top-leading. This will be the case for a scroll action,
            // but may not be the case on initial show.
            if (target.anchor?.x ?? 0) == 0 {
                if let selectedIndex {
                    let targetX = target.rect.origin.x + (context.containerSize.width / 2)
                    let dx = context.velocity.dx
                    var frame = elementPositions[selectedIndex]
                    var index = selectedIndex
                    if dx < 0 {
                        while index > 0 && targetX < frame.minX {
                            index -= 1
                            let prevFrame = elementPositions[index]
                            if prevFrame == .zero {
                                break
                            } else {
                                frame = prevFrame
                            }
                        }
                    } else if dx > 0 {
                        while index < elementPositions.count - 1 && targetX > frame.maxX {
                            index += 1
                            let nextFrame = elementPositions[index]
                            if nextFrame == .zero {
                                break
                            } else {
                                frame = nextFrame
                            }
                        }
                    }
                    target.rect.origin.x = frame.midX - (context.containerSize.width / 2)
                }
            }
        }
    }
    

    Also missing from the other answer was padding at the side of the HStack, to allow the first and last items to be selected. If the padding needed to be precise then we would need to know the widths of the first and last elements. But an easy solution is just to add horizontal padding equal to half the screen width.

    The screen width can either be measured by wrapping the ScrollView with a GeometryReader (as you were doing before), or by using .onGeometryChange and writing to a state variable. The updated example below uses the latter technique. This way, the height of the view is determined by the content, it is not greedy in the vertical axis.

    struct SnapPickerView: View {
        private var elements = ["Sirene 187", "FC Clubbing completely new", "John", "Malkovich", "Santander stret 126 hood", "5", "6", "7", "5", "6", "7", "5", "6", "7", "5", "6", "7", "5", "6", "7", "5", "6", "7", "5", "6", "7", "5", "6", "7", "5", "6", "7", "5", "6", "7", "FC Clubbing completely new 23"]
        @State private var elementPositions: [CGRect]
        @State private var selectedIndex: Int?
        @State private var scrollViewWidth = CGFloat.zero
        @Namespace private var ns
    
        init() {
            elementPositions = .init(repeating: .zero, count: elements.count)
        }
    
        var body: some View {
            ScrollView(.horizontal, showsIndicators: false) {
                HStack(spacing: 16) {
                    ForEach(Array(elements.enumerated()), id: \.offset) { index, element in
                        Text(element)
                            .padding()
                            .background {
                                RoundedRectangle(cornerRadius: 10)
                                    .fill(selectedIndex == index ? .blue.opacity(0.2) : .gray.opacity(0.1))
                            }
                            .contentShape(Rectangle())
                            .onTapGesture {
                                withAnimation {
                                    selectedIndex = index
                                }
                            }
                            .onGeometryChange(for: CGRect.self) { proxy in
                                proxy.frame(in: .named(ns))
                            } action: { frame in
                                if frame != elementPositions[index] {
                                    elementPositions[index] = frame
                                }
                            }
                    }
                }
                .padding(.horizontal, scrollViewWidth / 2)
                .coordinateSpace(name: ns)
                .scrollTargetLayout()
            }
            .scrollPosition(id: $selectedIndex, anchor: .center)
            .scrollTargetBehavior(MidPositionAligned(selectedIndex: selectedIndex, elementPositions: elementPositions))
            .onGeometryChange(for: CGFloat.self, of: \.size.width) { width in
                scrollViewWidth = width
            }
            .onAppear {
                selectedIndex = 10
            }
        }
    }
    

    Animation