深入了解 SwiftUI 5 中 ScrollView 的新功能 | 肘子的 Swift 记事本

ScrollView

ListForm 也可以创建滚动视图效果,但有时如果想要滚动任意的数据(例如我们手动创建的一些视图),就需要用 ScrollView

<aside> 💡 ScrollView 对待安全区域就像 ListForm 一样。它们的内容位于主界面的指示器的下面,但它们添加了一些额外的填充间距,以便整个视图完全可见,不会被挡住一部分。

</aside>


声明方法

// 例如,可以创建一个包含 100 个文本视图的滚动列表,如下所示:
ScrollView {
    VStack(spacing: 10) {
        ForEach(0..<100) {
            Text("Item \\($0)")
                .font(.title)
        }
    }
}

// 但由于 scrollView 是滚动方向的扩张型视图;上面例子中,它是垂直方向扩张,水平方向不扩张
// 因此在水平方向上,上面的滚动视图只占屏幕中间的一小块区域,这让滚动不方便,通常要让整个屏幕区域都支持滚动
// 为了获得这种行为,应该让 VStack 占用更多空间(scrollView 对滑动的响应区域,是其内部所有元素所占据的区域)
// 同时保持默认的居中对齐不变,修改如下:
ScrollView {
    VStack(spacing: 10) {
        ForEach(0..<100) {
            Text("Item \\($0)")
                .font(.title)
        }
    }
    .frame(maxWidth: .infinity)
}

使用惰性堆栈

<aside> 💡 这些惰性堆栈自动具有灵活的首选宽度,因此它们相比常规的 Stack,会自动占用更大的空间。使用惰性堆栈,在水平方向上可以使用文本周围的空白进行滚动;但如果切换到常规 Stack ,就只能在中间文本区域进行滚动,除非设置 .frame(maxWidth: .infinity)

</aside>

参见:LazyStack 惰性堆栈

使用惰性网格

参见:‣


参数:滚动轴

//垂直滚动
ScrollView(.vertical){ ... }

//水平滚动
ScrollView(.horizontal){ ... }

//支持两个方向滚动
ScrollView([.horizontal, .vertical]){ ... }

参数:隐藏滚动指示器

//隐藏滚动指示器
ScrollView(.horizontal, showsIndicators: false){...}

滚动修饰符

defaultScrollAnchor 从底部滚动

ScrollView 默认从顶部开始滚动,但如果您想创建像 Apple 的消息应用程序一样的 UI,您可以使用 defaultScrollAnchor() 修饰符要求滚动视图从底部开始初始锚点为 .bottom 。

ScrollView {
    ForEach(0..<50) { i in
        Text("Item \\(i)")
            .frame(maxWidth: .infinity)
            .padding()
            .background(.blue)
            .clipShape(.rect(cornerRadius: 25))
    }
}
.defaultScrollAnchor(.bottom)
// 如果 UI 在用户不滚动的情况下发生某种变化(例如,如果出现键盘,或者您调整滚动视图的大小),使用了该修饰符的滚动位置仍将保持锚定在底部。但是,如果用户手动调整滚动位置,它将正常自由滚动。

<aside> 💡 传入的参数可以是任何 UnitPoint ,因此您可以使用 .trailing 从右边缘启动水平滚动视图,也可以使用 UI 所需的任何精确值。

</aside>

scrollClipDisabled 禁用溢出裁切

ScrollView 会自动剪辑其内容,例如子视图超出滚动视图后,子视图就看不见了。但是,如果使用 scrollClipDisabled() 修饰符,则可以覆盖此默认行为,从而允许滚动视图溢出(超出也可以看见)。

这不会影响滚动视图的触摸区域,也就是说可滚动的触摸区域还是原来那么大。(虽然有些超出区域的内容被看见,但如果用户点击这些超出滚动区域的视图,其点击实际上无法触发滚动,它只会被下面的视图接收到。)因此,最好稍微限制一下,例如允许阴影在滚动区域之外流动,而不会过多影响其他视图。

// 该示例显示了一个 VStack ,其顶部和底部具有固定文本,中间有一个滚动区域。滚动视图将在顶部文本下方整齐地对齐,但当您滚动时将溢出:
VStack {
    Text("Fixed at the top")
        .frame(maxWidth: .infinity)
        .frame(height: 100)
        .background(.green)
        .foregroundStyle(.white)

    ScrollView {
        ForEach(0..<5) { i in
            Text("Scrolling")
                .frame(maxWidth: .infinity)
                .frame(height: 200)
                .background(.blue)
                .foregroundStyle(.white)
        }
    }
    .scrollClipDisabled()

    Text("Fixed at the bottom")
        .frame(maxWidth: .infinity)
        .frame(height: 100)
        .background(.green)
        .foregroundStyle(.white)
}

// 使用 scrollClipDisabled() 时了解两件额外的事情会很有帮助:
// 您可以添加自定义剪辑形状来限制内容溢出的距离。例如,添加 padding() 然后 clipShape(.rect) 意味着您会得到一点溢出,但不是无限的。
// 由于滚动视图现在与其周围环境重叠,因此您可能需要使用 zIndex() 来调整其垂直位置。例如,如果其他视图具有默认的 Z 索引,则在滚动视图上使用 zIndex(1) 将使其子视图渲染在其他视图上。

scrolIIndicators 隐藏指示器

除了通过上面设定的参数来隐藏滚动指示器,还可以通过添加 scrollIndicators() 修饰符来隐藏滚动指示器,这适用于 ScrollViewList 等视图。

List(1..<100) { i in
            Text("Row \\(i)")
}
.scrollIndicators(.hidden)

// 参数有4种选择,并且有细微的区别:
- .automatic:是在没有修饰符的情况下得到的结果 - SwiftUI 会做它认为最好的事情
- .visible:以在 iOS 上获取自动隐藏指示器,或在 macOS 上尊重用户的偏好
- .hidden:在 iOS 上隐藏指示器,并且在 macOS 上也大多隐藏它们(如果用户连接鼠标,滚动指示器将返回)
- .never:在 iOS 和 macOS 上隐藏指示器,无论用户使用什么触摸设备

scrollIndicatorsFlash 指示器闪烁

使用 scrollIndicatorsFlash() 修饰符可以控制 ScrollView 或 List 的滚动指示器何时应该闪烁,这是通知用户某些部分数据已更改的好方法。该方法在 List 与 ScrollView 上都可以适用。此修饰符有两种形式:

// 1.当滚动视图指示器,首次出现时是否应该闪烁
ScrollView {
    ForEach(0..<50) { i in
        Text("Item \\(i)")
            .frame(maxWidth: .infinity)
    }
}
.scrollIndicatorsFlash(onAppear: true)

// 2.当某个观察值更改时指示器是否应该闪烁
// 您可以提供一个自定义值来跟踪指示器是否应闪烁。这可以是任何 Equatable 值,只要该值发生变化,SwiftUI 就会闪烁指示器
// 因此,您可以增加一个整数,生成一个随机 UUID ,或者只是提供布尔值在 true 和 false 之间切换都行
struct ContentView: View {
    @State private var exampleState = false
    var body: some View {
        VStack {
            ScrollView {
                ForEach(0..<50) { i in
                    Text("Item \\(i)")
                        .frame(maxWidth: .infinity)
                        .background(.blue)
                        .foregroundStyle(.white)
                }
            }
            .scrollIndicatorsFlash(trigger: exampleState)
            Button("Flash!") {
                exampleState.toggle()
            }
        }
    }
}

contentMargins 缩进

ScrollView 的内容会在滚动方向上填充所有可用空间,而【滚动指示器】会贴着放置于屏幕边缘。使用 contentMargins() 修饰符,可以给【滚动内容】或【滚动指示器】,在指定的边上提供缩进

// 例如
ScrollView {
    ForEach(0..<50) { i in
        Text("Item \\(i)")
            .frame(maxWidth: .infinity)
            .foregroundStyle(.white)
            .background(.blue)
    }
}
// 这会将滚动视图的内容在每个边缘缩进 50 点,而不调整滚动指示器:
.contentMargins(50, for: .scrollContent)

// 如果只需要某些边缘,则可以指定单个值或您选择的选项集。例如,这仅在顶部边缘将内容缩进 150 点:
.contentMargins(.top, 150, for: .scrollContent)

// 这会向【滚动指示器】的顶部添加 100 点的边距,但其余部分保持不变
.contentMargins(.top, 100, for: .scrollIndicators)

scrollBounceBehavior 弹跳

// 滚动视图默认都会有一个弹跳效果,即就算内容太少不足以滚动时,也允许用户上下滑动,然后弹回来
// 如果想禁止弹跳效果,可以加上以下代码:根据内容决定是否支持弹跳
.scrollBounceBehavior(.basedOnSize)

scrollTargetBehavior 吸附

想要实现 ScrollView 在滚动时的吸附效果(也就是停在和内部元素对齐的位置),需要两个修饰符:

.scrollTargetBehavior() 有两种滚动方式:

// 1. viewAligned 每当我们放开手时,SwiftUI 都会自动确保滑动区域,回弹在 一个(滚动目标)视图的左边缘上
ScrollView(.horizontal) {
    LazyHStack {
            ForEach(0..<10) { i in
                RoundedRectangle(cornerRadius: 25)
                    .fill(Color(hue: Double(i) / 10, saturation: 1, brightness: 1).gradient)
                    .frame(width: 300, height: 100)
            }
    }
    .scrollTargetLayout()
}
.scrollTargetBehavior(.viewAligned)
.safeAreaPadding(.horizontal, 40)

// 2. 另一种滚动定位行为是 .paging ,它使 ScrollView 根据滚动方向恰好移动一个屏幕宽度或高度:
ScrollView {
    ForEach(0..<50) { i in
        Text("Item \\(i)")
            .font(.largeTitle)
            .frame(maxWidth: .infinity)
            .frame(height: 200)
            .background(.blue)
            .foregroundStyle(.white)
            .clipShape(.rect(cornerRadius: 20))
    }
}
.scrollTargetBehavior(.paging)

scrollDismissesKeyboard 关闭键盘

scrollDismissesKeyboard() 修饰符使我们能够精确控制当用户滚动时键盘应如何关闭。例如,我们将 TextField 和 TextEditor 放入滚动视图中,并让它们都以交互方式关闭键盘,如下所示:

struct ContentView: View {
    @State private var username = "Anonymous"
    @State private var bio = ""
    var body: some View {
        ScrollView {
            VStack {
                TextField("Name", text: $username)
                    .textFieldStyle(.roundedBorder)
                TextEditor(text: $bio)
                    .frame(height: 400)
                    .border(.quaternary, width: 1)
            }
            .padding(.horizontal)
        }
        .scrollDismissesKeyboard(.interactively)
    }
}

可以为 scrollDismissesKeyboard() 修饰符指定4个值之一,所有这些值都有各自的用途:

根据苹果的文档,文本编辑器应该建议使用 interactively,而其他视图应该使用 immediately,但目前情况似乎并非如此。

scrollTransition 自定义滚动

将 scrollTransition() 修饰符附加到子视图,就可以自定义视图如何进入和离开屏幕。该修饰符必须传递一个至少带有两个参数的闭包:

// 1.用 isIdentity 属性使子视图在靠近屏幕边缘时淡入和淡出
ScrollView {
    ForEach(0..<10) { i in
        RoundedRectangle(cornerRadius: 25)
            .fill(.blue)
            .frame(height: 80)
            .scrollTransition { content, phase in
                content
                    .opacity(phase.isIdentity ? 1 : 0)
                    .scaleEffect(phase.isIdentity ? 1 : 0.75)
                    .blur(radius: phase.isIdentity ? 0 : 10)
            }
            .padding(.horizontal)
    }
}

// 2.用 threshold 属性指定“只有当视图至少 90% 可见时”,才将其插入到视图层次结构中:
ScrollView {
    ForEach(0..<10) { i in
        RoundedRectangle(cornerRadius: 25)
            .fill(.blue)
            .frame(height: 80)
            .scrollTransition(.animated.threshold(.visible(0.9))) { content, phase in
                content
                    .opacity(phase.isIdentity ? 1 : 0)
                    .scaleEffect(phase.isIdentity ? 1 : 0.75)
                    .blur(radius: phase.isIdentity ? 0 : 10)
            }
            .padding(.horizontal)
    }
}

// 3.如果需要非常精确地控制所应用的效果,请读取过渡阶段的 value 
// 对于顶部前导阶段中的视图,该值为 -1;对于底部尾随阶段中的视图,该值为 1;对于所有其他视图,该值为 0
ScrollView {
    ForEach(0..<10) { i in
        RoundedRectangle(cornerRadius: 25)
            .fill(.blue)
            .frame(height: 80)
            .shadow(radius: 3)
            .scrollTransition { content, phase in
                content
                    .hueRotation(.degrees(45 * phase.value))
            }
            .padding(.horizontal)
    }
}

onScrollPhaseChange 检测是否滚动

该方法是 iOS 18 中的新功能。

onScrollPhaseChange() 修饰符让我们能够检测到滚动视图的移动何时发生某种变化。

// 例如,每当用户主动与之交互时,颜色就会从红色变为绿色:
struct ContentView: View {
    @State private var backgroundColor = Color.red
    var body: some View {
        ScrollView {
            backgroundColor
                .frame(height: 2000)
        }
        .onScrollPhaseChange { oldPhase, newPhase in
            if newPhase == .interacting {
                backgroundColor = .green
            } else {
                backgroundColor = .red
            }
        }
    }
}

// oldPhase 和 newPhase 值可以具有五个不同值之一:
// .animating :当滚动视图向一个特定视图移动时
// .decelerating :当用户松开手指并且滚动视图自然减慢速度时
// .idle :滚动视图没有移动或与之交互
// .interacting :用户现在将手指放在下方,无论是静止的还是移动的
// .tracking :当系统认为用户滚动事件可能即将到来时使用;我怀疑这个不太有帮助。
// 通过访问旧值和新值,您可以添加微妙的交互 - 也许在拖动触发一个结果后移动到空闲状态,而在动画触发不同结果后这样做。

onScrollGeometryChange 读取视图大小/位置

该方法是 iOS 18 中的新功能。

onScrollGeometryChange() 修饰符让我们在滚动视图更改其内容大小(它有多少内容)、内容偏移(用户滚动了多远)等时收到通知。该 API 有点难以理解,这里展示一个示例再进行解释。此代码显示了滚动的行列表。一开始只有 1 行,但每次按下按钮时都会添加另一行:

struct ContentView: View {

    @State private var counter = 1

		var body:some View {
        VStack {
            ScrollView {
                ForEach(0..<counter, id: \\.self) { i in
		                Text("Row \\(i)")
                }
            }
            .onScrollGeometryChange(for: Double.self) { geo in
                geo.contentSize.height
            } action: { oldValue, newValue in
		            print("Height is now \\(newValue)")
            }

            Button("Add a row") {
                counter += 1
            }
        }
    }
}

重要的部分在最后: onScrollGeometryChange() 。这里提供的参数是: