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!
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:
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 ScrollView
s 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 {
...
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 }
}
}