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

406 views Asked by At

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!

1

There are 1 answers

2
rob mayoff On BEST ANSWER

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