深入了解 SwiftUI 5 中 ScrollView 的新功能 | 肘子的 Swift 记事本
List
和 Form
也可以创建滚动视图效果,但有时如果想要滚动任意的数据(例如我们手动创建的一些视图),就需要用 ScrollView
。
ScrollView
是指定的滚动方向的扩张型视图,可以将滚动方向设置为:垂直、水平、或双向滚动ScrollView
就像子视图的容器。 当其中的子视图进入帧外时,用户可以滚动将帧外的子视图带入视图ScrollView
可以控制系统是否应在其旁边显示滚动指示器,这些小滚动条可以让用户了解内容有多大ScrollView
内包含 ForEach
视图,就类似列表 List
。 但注意这些行不可重复使用,出于内存和性能考虑最好限制行数<aside>
💡 ScrollView
对待安全区域就像 List
和 Form
一样。它们的内容位于主界面的指示器的下面,但它们添加了一些额外的填充间距,以便整个视图完全可见,不会被挡住一部分。
</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)
}
VStack
和 HStack
会预先加载所有内容。如果在 scrollView
中使用它们,里面子元素都是一开始就全部添加进视图的,即使在屏幕上看不见。SwiftUI 不会等到向下滚动才能看到它们,它只会立即创建它们。所以这可能会很慢。LazyVStack
和 LazyHStack
。它们的使用方式与常规堆栈完全相同,但会按需加载其内容(在实际显示之前它们不会创建视图)。因此最大限度地减少了所使用的系统资源量。<aside>
💡 这些惰性堆栈自动具有灵活的首选宽度,因此它们相比常规的 Stack,会自动占用更大的空间。使用惰性堆栈,在水平方向上可以使用文本周围的空白进行滚动;但如果切换到常规 Stack ,就只能在中间文本区域进行滚动,除非设置 .frame(maxWidth: .infinity)
</aside>
参见:‣
//垂直滚动
ScrollView(.vertical){ ... }
//水平滚动
ScrollView(.horizontal){ ... }
//支持两个方向滚动
ScrollView([.horizontal, .vertical]){ ... }
//隐藏滚动指示器
ScrollView(.horizontal, showsIndicators: false){...}
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>
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) 将使其子视图渲染在其他视图上。
除了通过上面设定的参数来隐藏滚动指示器,还可以通过添加 scrollIndicators()
修饰符来隐藏滚动指示器,这适用于 ScrollView
、List
等视图。
List(1..<100) { i in
Text("Row \\(i)")
}
.scrollIndicators(.hidden)
// 参数有4种选择,并且有细微的区别:
- .automatic:是在没有修饰符的情况下得到的结果 - SwiftUI 会做它认为最好的事情
- .visible:以在 iOS 上获取自动隐藏指示器,或在 macOS 上尊重用户的偏好
- .hidden:在 iOS 上隐藏指示器,并且在 macOS 上也大多隐藏它们(如果用户连接鼠标,滚动指示器将返回)
- .never:在 iOS 和 macOS 上隐藏指示器,无论用户使用什么触摸设备
使用 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()
}
}
}
}
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(.basedOnSize)
想要实现 ScrollView 在滚动时的吸附效果(也就是停在和内部元素对齐的位置),需要两个修饰符:
.scrollTargetLayout
:应用在滚动视图的子元素上,例如 HStack
,使在 HStack
内定义的每个视图都成为【滚动目标】.scrollTargetBehavior(.viewAligned)
:应用在 ScrollView
上,它会使这个滚动视图在所有【滚动目标】之间平滑移动scrollTargetLayout()
和 .scrollTargetBehavior(.viewAligned)
两者需要一起使用.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()
修饰符使我们能够精确控制当用户滚动时键盘应如何关闭。例如,我们将 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个值之一,所有这些值都有各自的用途:
.automatic
让 SwiftUI 根据当前滚动视图上下文,判断此刻最好的处理方法是什么.immediately
让键盘在发生任何滚动时,立即完全消失.interactively
使键盘与用户的手势一致地关闭(他们需要进一步滚动才能完全关闭).never
根据苹果的文档,文本编辑器应该建议使用 interactively
,而其他视图应该使用 immediately
,但目前情况似乎并非如此。
将 scrollTransition()
修饰符附加到子视图,就可以自定义视图如何进入和离开屏幕。该修饰符必须传递一个至少带有两个参数的闭包:
.identity
阶段,这意味着子视图在屏幕上可见的时候(值是 0).topLeading
阶段,根据滚动视图方向,子视图即将从顶部或前缘变成可见的阶段(值是 -1).bottomTrailing
阶段,它是 .topLeading
的相反,即子视图即将消失的阶段(值是 1)// 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)
}
}
该方法是 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 :当系统认为用户滚动事件可能即将到来时使用;我怀疑这个不太有帮助。
// 通过访问旧值和新值,您可以添加微妙的交互 - 也许在拖动触发一个结果后移动到空闲状态,而在动画触发不同结果后这样做。
该方法是 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()
。这里提供的参数是:
Double.self
。这意味着我们关注某种 Double
值。它没有说明它的含义,只是说它将是一个 Double
geo
参数,geo
是一个 ScrollGeometry
对象,它可以读取内容大小、偏移量、插图等。这必须返回一个 Double
,这就是我们所说的第一个参数中会发生的情况,并且您应该发回您想要观看的值action
,当第一个闭包的监视值发生更改时,就会调用该动作闭包