添加新内容时如何保持SwiftUI ScrollView的滚动位置?
解决ChatView滚动加载递归滚动问题
问题描述
开发ChatView时,需要实现滚动到顶部加载旧消息、滚动到底部加载新消息的功能,但目前存在以下问题:
- 加载新消息时,ScrollView会持续滚动到底部,递归触发加载操作
- 顶部加载旧消息的问题已通过
flipped()修饰符修复,但底部问题仍未解决
问题根源
- 无加载状态控制:每次新消息追加后,新的
messages.last会触发onAppear,递归调用loadMoreData - 翻转视图导致逻辑混淆:由于整个ScrollView和子视图都做了翻转,数组的首尾对应显示的底部和顶部,触发加载的判断逻辑需要对应调整
- 滚动位置自动绑定干扰:
scrollPosition的绑定在数据更新时会自动滚动到指定位置,加剧了递归滚动问题
修复后的代码
struct ChatMessage: View { let text: String var body: some View { HStack { Text(text) .foregroundStyle(.white) .padding() .background(.blue) .clipShape(RoundedRectangle(cornerRadius: 16)) .overlay(alignment: .bottomLeading) { Image(systemName: "arrowtriangle.down.fill") .font(.title) .rotationEffect(.degrees(45)) .offset(x: -10, y: 10) .foregroundStyle(.blue) } Spacer() } .padding(.horizontal) } } struct Message: Identifiable, Equatable { var id: Int var text: String } struct GoodChatView: View { @State var messages: [Message] = [] // 加载状态锁,防止递归触发 @State private var isLoadingOld = false @State private var isLoadingNew = false var body: some View { ScrollViewReader { scrollView in ScrollView { LazyVStack { ForEach(messages, id: \.id) { message in ChatMessage(text: "\(message.text)") .flippedUpsideDown() .onAppear { // 翻转后,messages.first对应显示的底部(触发新消息加载) if message == messages.first && !isLoadingNew { print("加载新消息") loadNewData() } // 翻转后,messages.last对应显示的顶部(触发旧消息加载) if message == messages.last && !isLoadingOld { print("加载旧消息") loadMoreData() } } } } .scrollTargetLayout() } .flippedUpsideDown() .onAppear { // 初始化加载20条消息 messages = (1...20).map { Message(id: $0, text: "\($0)") } } } } func loadMoreData() { isLoadingOld = true DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { let startId = messages.last?.id ?? 0 // 加载旧消息,ID比现有最大ID更大(模拟历史消息) let newMessages = (startId + 1...startId + 20).map { Message(id: $0, text: "\($0)") } messages.append(contentsOf: newMessages) isLoadingOld = false } } func loadNewData() { isLoadingNew = true DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { let startId = messages.first?.id ?? 0 // 加载新消息,ID比现有最小ID更小(模拟最新消息) let newMessages = (startId - 20...startId - 1).map { Message(id: $0, text: "\($0)") } messages.insert(contentsOf: newMessages, at: 0) isLoadingNew = false } } } struct FlippedUpsideDown: ViewModifier { func body(content: Content) -> some View { content .rotationEffect(.radians(Double.pi)) .scaleEffect(x: -1, y: 1, anchor: .center) } } extension View { func flippedUpsideDown() -> some View { modifier(FlippedUpsideDown()) } }
关键修复点
- 添加加载状态锁:
isLoadingOld和isLoadingNew变量,确保同一时间只触发一次加载,阻断递归调用 - 修正触发逻辑:结合翻转视图的实际显示位置,调整触发加载的判断条件,匹配聊天场景的滚动方向
- 移除自动滚动绑定:去掉
scrollPosition(id: $messageId),避免数据更新时自动滚动引发的连锁问题 - 优化消息ID生成:让旧消息ID大于现有最大ID、新消息ID小于现有最小ID,符合聊天记录的时间逻辑
替代实现思路
如果不想使用翻转视图的方式,可以直接调整消息数组顺序,让最新消息在数组末尾,通过ScrollViewReader手动控制滚动:
- 初始化时将最新消息放在数组末尾
- 加载新消息时直接追加到数组末尾,调用
scrollView.scrollTo(messages.last?.id)滚动到底部 - 加载旧消息时插入到数组开头,保持当前滚动位置不变(无需自动滚动到顶部)
这种方式更符合直觉,避免翻转视图带来的逻辑混淆。
内容的提问来源于stack exchange,提问作者Franky11
相关产品推荐
相关产品推荐

