You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

ScrollView嵌套LazyVStack首次自动滚动抖动问题求助

问题原因

首次加载消息时滚动抖动的核心原因有两点:

  1. LazyVStack懒加载特性:消息加载完成后,LazyVStack不会立刻渲染所有MessageView,此时调用scrollTo会基于未完全渲染的布局计算滚动位置,后续元素渲染完成后布局变化导致滚动偏移。
  2. 图片加载延迟: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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.15 03:25:33