实现 ScrollView 默认从底部开始显示
了解如何实现默认从底部开始显示 ScrollView 视图,并在内容发生改变时滚动到指定位置。
在之前的这篇文章中,我们讨论了如何自定义 ScrollView 滚动时的对齐行为:
这篇文章,我们将探讨如何实现从底部开始显示 ScrollView。
设置滚动初始位置
使用 ScrollViewReader 实现
在 iOS17 以前的版本中,一种常见的实现方式是使用 ScrollViewReader
搭配 scrollTo
滚动到底部:
struct ChatList: View {
private var records: [Record]
var body: some View {
ScrollViewReader { proxy in
ScrollView(.vertical) {
VStack(spacing: 0) {
ForEach(records) { record in
RecordRowCardView(record: record)
.id("record-\(record.id)")
}
}
.padding(.horizontal)
}
.onAppear {
if let lastRecord = records.last {
proxy.scrollTo(lastRecord.id, anchor: .bottom)
}
}
}
}
}
在上述代码中,我们通过获取最后一条 records.last.id
来实现滚动到底部。
另一种更简单的方式,你可以在 ForEach 底部添加一个空的 Text 组件,并直接滚动到该组件的位置,可以实现一样的效果:
struct ChatList: View {
private var records: [Record]
var body: some View {
ScrollViewReader { proxy in
ScrollView(.vertical) {
VStack(spacing: 0) {
ForEach(records) { record in
RecordRowCardView(record: record)
.id("record-\(record.id)")
}
// 空组件
Text("").id("bottomID")
}
.padding(.horizontal)
}
.onAppear {
proxy.scrollTo("bottomID", anchor: .bottom)
}
}
}
}
使用 defaultScrollAnchor
修饰器( iOS17 及以上)
在 iOS 17 和 macOS 14 中,SwiftUI 引入了一个新的修饰器——defaultScrollAnchor
。使用它可以更方便的设置 ScrollView 或 List 组件的初始位置,而无需手动调用滚动代理来滚动到特定位置。
通过设置 .defaultScrollAnchor(.bottom)
,ScrollView 默认即可从底部开始显示,这对于 LazyVStack
这类懒加载组件仍然有效:
struct ChatList: View {
private var records: [Record]
var body: some View {
ScrollView(.vertical) {
VStack(spacing: 0) {
ForEach(records) { record in
// 省略
}
}
.padding(.horizontal)
}
.defaultScrollAnchor(.bottom)
}
}
需要注意以下几点:
- defaultScrollAnchor 只影响初始位置,后续内容变化时不会再滚动。
- defaultScrollAnchor 没有初始化滚动动画。
- 当启动键盘时,ScrollView 内容似乎不会自动升高。
当内容变化时滚动到特定位置
在 iOS17 及以上版本,可以使用 ScrollPosition
修饰器来实现。
绑定 SwiftData 对象和 ScrollPositioon 修饰器
为了使用 ScrollPosition 修饰器,需要创建一个 @State 对象。这里我重点讲解在使用 SwiftData 对象时,一些需要注意的点。
如果使用 SwiftData,可以使用 SwiftData 自带的 ID 参数,像下面这样定义 scrolledID:
@State var scrolledID: PersistentIdentifier?
然后像下面这样使用:
struct ChatList: View {
private var records: [Record]
@State var scrolledID: FinancialRecord.ID?
var body: some View {
ScrollView(.vertical) {
VStack(spacing: 0) {
ForEach(records) { record in
// 省略
}
}
}
.defaultScrollAnchor(.bottom)
.onChange(of: records.count) { oldCount, newCount in
if newCount > oldCount {
if let lastRecord = records.last {
withAnimation(.spring) {
scrolledID = lastRecord.persistentModelID
}
}
}
}
}
}
使用 .scrollTargetLayout() 修饰器导致卡顿
在上述代码中,当在 LazyVStack 上使用 .scrollTargetLayout()
修饰器,虽然它不影响滚动功能,但会导致滑动 ScrollView 组件时非常卡顿。
struct ChatList: View {
private var records: [Record]
@State var scrolledID: FinancialRecord.ID?
var body: some View {
ScrollView(.vertical) {
VStack(spacing: 0) {
ForEach(records) { record in
// 省略
}
}
.scrollTargetLayout()
}
.defaultScrollAnchor(.bottom)
.onChange(of: records.count) { oldCount, newCount in
if newCount > oldCount {
if let lastRecord = records.last {
withAnimation(.spring) {
scrolledID = lastRecord.persistentModelID
}
}
}
}
}
}
我还不清楚具体的原因,不确定是否是 BUG 导致——在 iOS17 上似乎并不明显,但在 iOS18 上卡顿明显。一但移除 .scrollTargetLayout()
代码,则卡顿消失。