实现 ScrollView 默认从底部开始显示

了解如何实现默认从底部开始显示 ScrollView 视图,并在内容发生改变时滚动到指定位置。

实现 ScrollView 默认从底部开始显示

在之前的这篇文章中,我们讨论了如何自定义 ScrollView 滚动时的对齐行为:

使用 scrollTargetBehavior 自定义 ScrollView 对齐效果
了解如何使用 iOS17 上新引入的 scrollTargetBehavior 修饰器,为你的滚动视图添加更多效果。

这篇文章,我们将探讨如何实现从底部开始显示 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)
                }
            }
        }
    }
}
0:00
/0:01

在上述代码中,我们通过获取最后一条 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)
            }
        }
    }
}
💡
如果你使用 LazyVStack 进行懒加载,初始化滚动可能不生效,因为初始化时内容还未加载。你可以通过为滚动添加延迟,或添加一个记录数据加载的状态,并在数据加载完成后执行滚动。

使用 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 修饰器来实现。

scrollPosition(id:anchor:) | Apple Developer Documentation
Associates a binding to be updated when a scroll view within this view scrolls.

绑定 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() 代码,则卡顿消失。