实现 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 组件时非常卡顿。

首先确定,不是 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()
}