7

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 Answer 1

20

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 }
    }
}
Sign up to request clarification or add additional context in comments.

8 Comments

Yes, thank you Rob! This was very informative. Question - why didn't we need to use .scrollTargetLayout() on the LazyVStack? The documentation has me to believe you need that to have views even considered for scrollTargetBehavior
The scrollTargetLayout documentation says “This modifier works together with the ViewAlignedScrollTargetBehavior to ensure that scroll views align to view based content.” Since my answer does not use ViewAlignedScrollTargetBehavior, there is no reason for it to use scrollTargetLayout.
@robmayoff thank you great work. I was wondering two things though: 1) In the WWDC23 intro video he mentions that LazyStacks are somewhat special, since views have not been created yet (thus I assume no frames should be available) does the clear-background trick still work in this case ? 2) How did Apple achieve .viewAligned behavior using a static singleton without passing the frames into it ? I checked the TargetContext and couldn't find any hints, except maybe somehow using a Environment State.
Things built in to SwiftUI can do lots of things that we cannot do from the outside. That is one of the main consequences of SwiftUI’s declarative, value-type API style, compared to the imperative, reference-type API style of UIKit.
Amazing example, thanks @robmayoff
|

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.