ScrollView嵌套LazyVStack首次自动滚动抖动问题求助
问题原因
首次加载消息时滚动抖动的核心原因有两点:
- LazyVStack懒加载特性:消息加载完成后,LazyVStack不会立刻渲染所有
MessageView,此时调用scrollTo会基于未完全渲染的布局计算滚动位置,后续元素渲染完成后布局变化导致滚动偏移。 - 图片加载延迟:
MessageView中的WebImage加载完成后会改变视图高度,提前触发的滚动会因为图片未加载而计算错误,图片加载后视图高度增加,出现滚动位置上移的抖动现象。
解决方案
需要确保所有视图元素(包括图片)完全渲染并稳定布局后,再触发滚动到底部,具体修改分为三部分:
1. 修改MessageView,添加图片加载完成回调
给MessageView增加图片加载完成的通知能力,让父视图能追踪所有图片的加载状态:
fileprivate struct MessageView: View { let message: Message var onImageLoaded: (() -> Void)? // 新增图片加载完成回调 @State private var imageLoaded = false // 标记当前图片是否已加载 var body: some View { HStack { VStack(alignment: .leading, spacing: 1) { Text(message.user.isCurrentUser == true ? "You" : "\(message.user.username)") .foregroundColor(message.user.isCurrentUser == true ? .customGreen : .customBlue) .multilineTextAlignment(.leading) .font(.default(size: 16)) .padding(.bottom, 1) if let imageURL = message.postSource?.imageURL, !imageURL.isEmpty { VStack(alignment: .leading) { WebImage(url: .init(string: imageURL)) .resizable() .indicator(.activity) .scaledToFill() .frame(width: UIScreen.main.bounds.width / 2, height: UIScreen.main.bounds.width / 1.45) .cornerRadius(25) .onSuccess { _, _ in // 监听图片加载成功事件 if !imageLoaded { imageLoaded = true onImageLoaded?() } } } } Text(message.text) .foregroundColor(.white) .multilineTextAlignment(.leading) .font(.default(size: 16)) } Spacer() } .padding(.bottom, 8) .padding(.horizontal) .background(Color.black) } }
2. 修改MessagesView,追踪视图渲染与图片加载状态
在MessagesView中监听消息变化,先触发一次基础滚动(针对无图片的消息),再等待所有图片加载完成后再次滚动,确保最终位置正确:
struct MessagesView: View { @StateObject private var viewModel = ViewModel() let currentChatRoom: ChatRoom @State private var loadedImageCount = 0 // 已加载完成的图片数量 var body: some View { ZStack { Color.black.ignoresSafeArea() VStack { ScrollView { ScrollViewReader { proxy in LazyVStack { ForEach(viewModel.messages) { message in MessageView(message: message) { // 绑定图片加载回调 loadedImageCount += 1 checkIfAllImagesLoaded(proxy: proxy) } .id(message.id) .onTapGesture { viewModel.shouldDismissKeyboard = true } } } .onChange(of: viewModel.messages) { newMessages in loadedImageCount = 0 // 消息更新时重置加载计数 guard !newMessages.isEmpty else { return } // 先触发一次滚动,处理无图片的消息场景 DispatchQueue.main.async { proxy.scrollTo(newMessages.last!.id, anchor: .bottom) } // 如果没有带图片的消息,无需后续处理 let imageMessageCount = newMessages.filter { $0.postSource?.imageURL != nil && !($0.postSource?.imageURL.isEmpty ?? true) }.count if imageMessageCount == 0 { return } } } } VStack(alignment: .leading) { if chatEnvironment.isOtherUserTyping { TypingIndicationView() } BottomView() .padding(.bottom, 4) } } } .onAppear { viewModel.setUp(currentChatRoom: currentChatRoom) } } // 检查所有图片是否加载完成,完成后滚动到底部 private func checkIfAllImagesLoaded(proxy: ScrollViewProxy) { let imageMessageCount = viewModel.messages.filter { $0.postSource?.imageURL != nil && !($0.postSource?.imageURL.isEmpty ?? true) }.count if loadedImageCount == imageMessageCount { withAnimation { proxy.scrollTo(viewModel.messages.last!.id, anchor: .bottom) } } } }
3. 简化ViewModel的滚动逻辑
现在视图会自行处理首次加载的滚动时机,ViewModel中可以移除首次加载时的scrollToBottom调用,保留新消息和发送消息时的滚动逻辑即可:
class ViewModel: ObservableObject { @Published var messages = [Message]() @Published var text = "" @Published var shouldScrollToMessageId: String? @Published var currentChatRoom: ChatRoom? private var isInitialized = false func setUp(currentChatRoom: ChatRoom) { guard !isInitialized else { return } isInitialized.toggle() self.currentChatRoom = currentChatRoom getFirstBatchOfMessages(chatRoom: currentChatRoom) subscribeToNewMessages() } private func getFirstBatchOfMessages(chatRoom: ChatRoom) { messagesService.getMessages(chatRoomId: chatRoom.id) { [weak self] messages in DispatchQueue.main.async { self?.messages = messages } // 移除此处的scrollToBottom调用,由视图自行处理首次滚动 } } private func subscribeToNewMessages() { // ... 原有逻辑 if !newMessages.isEmpty { self?.scrollToBottom(delay: 0) } } func scrollToBottom(delay: TimeInterval) { DispatchQueue.main.asyncAfter(deadline: .now() + delay) { self.shouldScrollToMessageId = self.messages.last?.id } } func sendMessage() { // ... 原有逻辑 scrollToBottom(delay: 0) } }
额外优化建议
- 如果消息数量极大,LazyVStack的懒加载可能导致部分消息未渲染,可在
onChange(of: viewModel.messages)中添加50ms左右的延迟再触发首次滚动,给LazyVStack足够的时间渲染可见区域的消息。 - 避免直接使用
UIScreen.main.bounds计算尺寸,改用GeometryReader获取父视图尺寸,适配不同设备和旋转场景。
内容的提问来源于stack exchange,提问作者Sotiris Kaniras
相关产品推荐
相关产品推荐

