如何在 ScrollView 中实现居中对齐单元格的自定义 SwiftUI ScrollTargetBehavior?

ric*_*rdo 3 scrollview snapping swift swiftui

我目前正在开发一个 SwiftUI 项目,我需要创建一个自定义垂直 ScrollView。要求是当用户停止滚动时,此 ScrollView 中的单元格捕捉到视图的中心。我知道 SwiftUI 的 ScrollView 通过修饰符提供了一定程度的自定义.scrollTargetBehavior(_:),但我发现的文档和示例并没有完全涵盖这种特定情况。

我尝试使用基本的scrollTargetBehavior .viewAligned,但捕捉行为不包括我正在寻找的捕捉效果。我知道 UIKit 通过 UICollectionView 和自定义布局属性提供了对滚动行为更精细的控制,但我的目标是纯粹在 SwiftUI 中实现这一点。

任何帮助将不胜感激。

干杯!

rob*_*off 5

为了提供一个有效的示例,我将使用许多这样的实例作为以下内容ScrollView

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

这会绘制一张中间有十字的扑克牌。ScrollView当卡片停止滚动时,十字可以很容易地看出卡片是否居中。

\n

让我们从ScrollView包含整副牌的基本设置开始:

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

它看起来像这样:

\n

滚动视图显示红心 A 和两颗红心中的一小部分,并有一条垂直居中的绿色水平线

\n

绿线位于 的垂直中心ScrollView。如果卡片的十字线与绿线对齐,我们就可以判断卡片居中。

\n

为了使ScrollView居中卡片停止滚动,我们需要编写ScrollTargetBehavior. 通过阅读文档(特别是ScrollTargetBehaviorContext和的文档ScrollTarget),我们可以推断我们的自定义ScrollTargetBehavior需要访问 s 坐标空间中的卡片视图的框架ScrollView

\n

为了收集这些帧,我们需要使用SwiftUI 的 \xe2\x80\x9cpreference\xe2\x80\x9d 系统。首先,我们需要一个类型来收集卡框:

\n
struct CardFrames: Equatable {\n    var frames: [Int: CGRect] = [:]\n}\n
Run Code Online (Sandbox Code Playgroud)\n

接下来,我们需要协议PreferenceKey自定义实现。我们不妨使用CardFrames类型作为键:

\n
extension CardFrames: PreferenceKey {\n    static var defaultValue: Self { .init() }\n\n    static func reduce(value: inout Self, nextValue: () -> Self) {\n        value.frames.merge(nextValue().frames) { $1 }\n    }\n}\n
Run Code Online (Sandbox Code Playgroud)\n

我们需要添加一个@State属性来存储收集到的帧:

\n
struct ContentView: View {\n\n    //  Add this property to ContentView\n    @State var cardFrames: CardFrames = .init()\n\n    var body: some View {\n    ...\n
Run Code Online (Sandbox Code Playgroud)\n

我们还需要NamedCoordinateSpace为定义 a ScrollView

\n
struct ContentView: View {\n\n    @State var cardFrames: CardFrames = .init()\n\n    //  Add this property to ContentView\n    private static let geometry = NamedCoordinateSpace.named("geometry")\n\n    var body: some View {\n    ...\n
Run Code Online (Sandbox Code Playgroud)\n

ScrollView接下来,我们需要通过coordinateSpace向 中添加修饰符将该坐标空间应用于 的内容LazyVStack

\n
        ScrollView {\n            LazyVStack {\n                ForEach(0 ..< 52) { i in\n                    Card(i: i)\n                        .frame(height: 500)\n                }\n            }\n\n            //  Add this modifier to LazyVStack\n            .coordinateSpace(Self.geometry)\n        }\n
Run Code Online (Sandbox Code Playgroud)\n

要读取 a 的框架Card并设置首选项,我们使用常见的 SwiftUI 模式:使用修饰符添加包含backgrounda的 a :GeometryReaderColor.clearpreference

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

现在我们可以使用修饰符读出CardFrames首选项并将其存储在属性中:@StateonPreferenceChange

\n
        ScrollView {\n           ...\n        }\n        .overlay {\n            Rectangle()\n                .frame(height: 1)\n                .foregroundStyle(.green)\n        }\n        //  Add this modifier to ScrollView\n        .onPreferenceChange(CardFrames.self) { cardFrames = $0 }\n
Run Code Online (Sandbox Code Playgroud)\n

这就是收集卡框并使它们在cardFrames属性中可用的所有代码。

\n

现在我们准备编写一个自定义的ScrollTargetBehavior. 我们的自定义行为调整 ,ScrollTarget使其中点成为最近卡片的中点:

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

最后,我们使用scrollTargetBehavior修饰符将自定义行为应用于ScrollView

\n
        ScrollView {\n            ...\n        }\n        //  Add this modifier to ScrollView\n        .scrollTargetBehavior(CardFramesScrollTargetBehavior(cardFrames: cardFrames))\n        .overlay {\n            ...\n
Run Code Online (Sandbox Code Playgroud)\n

滚动目标行为演示

\n

我注意到,当向上滚动并落在 3\xe2\x99\xa5\xef\xb8\x8e 卡上时,它并不完全居中。我认为这是 SwiftUI 的一个错误。

\n

这是ContentView包含所有添加内容的最终版本:

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