实现 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 组件时非常卡顿。
首先确定,不是 scrollTargetLayout 的原因。
目前测试发现:
- 不使用 SwiftData 对象时,运行流畅。
- 使用 SwiftData 对象时,无论使用 PersistentIdentifer 还是 Int、String 类型,都会卡顿。
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()
代码,则卡顿消失。
2025 年 2 月 6 日更新:
经过测试,和 SwiftData 相关,如果 currentPostId 绑定普通的 Int 类型,则运行流畅。下面这个代码运行流畅。虽然在控制台仍然看到重绘消息,但是 UI 非常流畅,没有任何卡顿 —— 说明重绘并不一定导致卡顿。

//
// ContentView.swift
// Tastup
//
// Created by Ivens Liao on 2025/2/6.
//
import SwiftData
import SwiftUI
// 创建新的数据模型
struct PostItem: Identifiable {
let id = UUID()
let image: UIImage
let title: String
let description: String
let rating: Double
let location: String
let createdAt: Date = .now
}
struct ContentView: View {
@State private var posts: [PostItem] = samplePosts
@State private var showingCreatePost = false
@State private var currentPostId: Int?
var body: some View {
let _ = Self._printChanges()
NavigationStack {
ZStack {
Group {
if posts.isEmpty {
ContentUnavailableView(
"还没有美食记录",
systemImage: "fork.knife",
description: Text("点击右上角的 + 按钮开始记录你的美食之旅")
)
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
ForEach(
Array(posts.enumerated()), id: \.element.id
) { index, post in
PostItemView(post: post)
.id(index)
}
}
.scrollTargetLayout()
}
.safeAreaPadding()
.scrollPosition(id: $currentPostId)
.scrollTargetBehavior(.viewAligned)
.onChange(of: currentPostId) {
print(
"scrolled to index: \(String(describing: currentPostId))"
)
}
}
}
.navigationTitle("Food Mate")
.toolbarTitleDisplayMode(.inlineLarge)
.toolbarRole(.editor)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showingCreatePost = true
} label: {
Image(systemName: "plus")
}
.sensoryFeedback(.impact, trigger: showingCreatePost)
}
}
.sheet(isPresented: $showingCreatePost) {
// 注意:CreatePostView 也需要相应修改
Text("创建新记录") // 临时替代 CreatePostView
}
}
}
}
}
// PostItemView 替代原来的 PostCardView
struct PostItemView: View {
let post: PostItem
var body: some View {
VStack(alignment: .leading) {
Image(uiImage: post.image)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 300, height: 200)
.clipShape(RoundedRectangle(cornerRadius: 12))
Text(post.title)
.font(.headline)
Text(post.description)
.font(.subheadline)
.foregroundStyle(.secondary)
HStack {
Image(systemName: "star.fill")
.foregroundStyle(.yellow)
Text(String(format: "%.1f", post.rating))
Spacer()
Image(systemName: "location.fill")
.foregroundStyle(.secondary)
Text(post.location)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.frame(width: 300)
.padding()
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
}
// 示例数据
private let samplePosts = [
PostItem(
image: UIImage(named: "food_01")!,
title: "美味的寿司",
description: "今天在一家新开的日料店尝试了他们的招牌寿司,非常新鲜!",
rating: 4.5,
location: "东京寿司店"
),
PostItem(
image: UIImage(named: "food_02")!,
title: "家常小炒",
description: "简单的家常菜,有时候反而最令人怀念。",
rating: 4.0,
location: "我的厨房"
),
PostItem(
image: UIImage(named: "food_03")!,
title: "下午茶时光",
description: "和朋友一起享受悠闲的下午茶时光,配上精致的甜点。",
rating: 5.0,
location: "咖啡馆"
),
]
#Preview {
ContentView()
}