swiftswiftuiscrollviewsnapping

How to Implement Custom SwiftUI ScrollTargetBehavior for Center-Snapping Cells in a ScrollView?


I'm currently working on a SwiftUI project where I need to create a custom vertical ScrollView. The requirement is for the cells within this ScrollView to snap to the center of the view when the user stops scrolling. I understand that SwiftUI's ScrollView provides some level of customization through the .scrollTargetBehavior(_:) modifier, but the documentation and examples I've found don't quite cover this specific case.

I've tried using the basic scrollTargetBehavior .viewAligned, but the snapping behavior doesn't include the snapping effect I'm looking for. I'm aware that UIKit provides more granular control over scrolling behavior with UICollectionView and custom layout attributes, but I'm aiming to achieve this purely within the SwiftUI.

Any help would be highly appreciated.

Cheers!


Solution

  • For the sake of a working example, I'm going to use many instances of this as the content of the ScrollView:

    struct Card: View {
        let i: Int
    
        var body: some View {
            let suit = ["heart", "spade", "diamond", "club"][i / 13]
            let rank = i == 9 ? "10" : String("A23456789_JQK".dropFirst(i % 13).prefix(1))
            let color = (i / 13).isMultiple(of: 2) ? Color.red : Color.black
            let label = VStack { Text(rank); Image(systemName: "suit.\(suit).fill") }.padding(12)
    
            RoundedRectangle(cornerRadius: 10, style: .circular)
                .fill(.white)
                .overlay(alignment: .topLeading) { label }
                .overlay(alignment: .bottomTrailing) { label.rotationEffect(.degrees(180)) }
                .foregroundStyle(color)
                .font(.system(size: 40))
                .overlay {
                    Canvas { gc, size in
                        gc.translateBy(x: 0.5 * size.width, y: 0.5 * size.height)
                        gc.stroke(
                            Path {
                                $0.move(to: .init(x: -10, y: -10))
                                $0.addLine(to: .init(x: 10, y: 10))
                                $0.move(to: .init(x: 10, y: -10))
                                $0.addLine(to: .init(x: -10, y: 10))
                            },
                            with: .color(color)
                        )
                    }
                }
                .padding()
                .compositingGroup()
                .shadow(radius: 1.5, x: 0, y: 0.5)
        }
    }
    

    This draws a sort of playing card with a cross in the center. The cross will make it easy to see whether the ScrollView centers a card when it stops scrolling.

    Let's start with a basic ScrollView setup containing the full deck of cards:

    struct BasicContentView: View {
        var body: some View {
            ScrollView {
                LazyVStack {
                    ForEach(0 ..< 52) { i in
                        Card(i: i)
                            .frame(height: 500)
                    }
                }
            }
            .overlay {
                Rectangle()
                    .frame(height: 1)
                    .foregroundStyle(.green)
            }
        }
    }
    

    It looks like this:

    scroll view showing the ace of hearts and a little of the two of hearts, with a green horizontal line centered vertically

    The green line is at the vertical center of the ScrollView. We can tell that a card is centered if the card's cross lines up with the green line.

    To make the ScrollView stop scrolling with a centered card, we need to write a custom implementation of ScrollTargetBehavior. By reading the documentation (and in particular the documentation of ScrollTargetBehaviorContext and ScrollTarget), we can infer that our custom ScrollTargetBehavior needs access to the frames of the card views, in the ScrollViews coordinate space.

    To collect those frames, we need to use SwiftUI's “preference” system. First, we need a type to collect the card frames:

    struct CardFrames: Equatable {
        var frames: [Int: CGRect] = [:]
    }
    

    Next, we need a custom implementation of the PreferenceKey protocol. We might as well use the CardFrames type as the key:

    extension CardFrames: PreferenceKey {
        static var defaultValue: Self { .init() }
    
        static func reduce(value: inout Self, nextValue: () -> Self) {
            value.frames.merge(nextValue().frames) { $1 }
        }
    }
    

    We need to add a @State property to store the collected frames:

    struct ContentView: View {
    
        // 👉 Add this property to ContentView
        @State var cardFrames: CardFrames = .init()
    
        var body: some View {
        ...
    

    We also need to define a NamedCoordinateSpace for the ScrollView:

    struct ContentView: View {
    
        @State var cardFrames: CardFrames = .init()
    
        // 👉 Add this property to ContentView
        private static let geometry = NamedCoordinateSpace.named("geometry")
    
        var body: some View {
        ...
    

    Next we need to apply that coordinate space to the content of the ScrollView, by adding a coordinateSpace modifier to the LazyVStack:

            ScrollView {
                LazyVStack {
                    ForEach(0 ..< 52) { i in
                        Card(i: i)
                            .frame(height: 500)
                    }
                }
    
                // 👉 Add this modifier to LazyVStack
                .coordinateSpace(Self.geometry)
            }
    

    To read the frame of a Card and set the preference, we use a common SwiftUI pattern: add a background containing a GeometryReader containing a Color.clear with a preference modifier:

                        Card(i: i)
                            .frame(height: 500)
                            // 👉 Add this modifier to LazyVStack
                            .background {
                                GeometryReader { proxy in
                                    Color.clear
                                        .preference(
                                            key: CardFrames.self,
                                            value: CardFrames(
                                                frames: [i: proxy.frame(in: Self.geometry)]
                                            )
                                        )
                                }
                            }
    

    Now we can read out the CardFrames preference and store it in the @State property, by using the onPreferenceChange modifier:

            ScrollView {
               ...
            }
            .overlay {
                Rectangle()
                    .frame(height: 1)
                    .foregroundStyle(.green)
            }
            // 👉 Add this modifier to ScrollView
            .onPreferenceChange(CardFrames.self) { cardFrames = $0 }
    

    That is all the code to collect the card frames and make them available in the cardFrames property.

    Now we're ready to write a custom ScrollTargetBehavior. Our custom behavior adjusts the ScrollTarget so that its midpoint is the midpoint of the nearest card:

    struct CardFramesScrollTargetBehavior: ScrollTargetBehavior {
        var cardFrames: CardFrames
    
        func updateTarget(_ target: inout ScrollTarget, context: TargetContext) {
            let yProposed = target.rect.midY
            guard let nearestEntry = cardFrames
                .frames
                .min(by: { ($0.value.midY - yProposed).magnitude < ($1.value.midY - yProposed).magnitude })
            else { return }
            target.rect.origin.y = nearestEntry.value.midY - 0.5 * target.rect.size.height
        }
    }
    

    Finally, we use the scrollTargetBehavior modifier to apply our custom behavior to the ScrollView:

            ScrollView {
                ...
            }
            // 👉 Add this modifier to ScrollView
            .scrollTargetBehavior(CardFramesScrollTargetBehavior(cardFrames: cardFrames))
            .overlay {
                ...
    

    demo of scroll target behavior

    I noticed that, when scrolling back up and landing on the 3♥︎ card, it's not quite centered. I think that's a SwiftUI bug.

    Here's the final ContentView with all the additions:

    struct ContentView: View {
        @State var cardFrames: CardFrames = .init()
    
        private static let geometry = NamedCoordinateSpace.named("geometry")
    
        var body: some View {
            ScrollView {
                LazyVStack {
                    ForEach(0 ..< 52) { i in
                        Card(i: i)
                            .frame(height: 500)
                            .background {
                                GeometryReader { proxy in
                                    Color.clear
                                        .preference(
                                            key: CardFrames.self,
                                            value: CardFrames(
                                                frames: [i: proxy.frame(in: Self.geometry)]
                                            )
                                        )
                                }
                            }
                    }
                }
                .coordinateSpace(Self.geometry)
            }
            .scrollTargetBehavior(CardFramesScrollTargetBehavior(cardFrames: cardFrames))
            .overlay {
                Rectangle()
                    .frame(height: 1)
                    .foregroundStyle(.green)
            }
            .onPreferenceChange(CardFrames.self) { cardFrames = $0 }
        }
    }