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

iOS SwiftUI实现类ChatGPT聊天界面的滚动定位问题求助

问题:实现类似ChatGPT的聊天界面滚动逻辑

需求:

  • 用户发送消息时,之前的消息从顶部移出视野,最新用户消息显示在顶部,助手回复紧随其下
  • 用户向下滚动时,之前的消息自然加载,无卡顿或突兀跳转

当前实现问题:

  • 无法将最新用户消息精准定位到屏幕顶部
  • 滚动状态切换逻辑不完善

当前代码

import SwiftUI

struct ChatView: View {
    @State private var messages: [ChatMessage] = [
        ChatMessage(text: "Hello! How can I assist you today?", isAssistant: true),
        ChatMessage(text: "I need help with SwiftUI!", isAssistant: false),
        ChatMessage(text: "Sure! What do you need help with in SwiftUI?", isAssistant: true)
    ]
    @State private var scrollOffset: [UUID: CGFloat] = [:]
    @State private var isNewMessage: Bool = false
    
    var body: some View {
        VStack {
            ScrollViewReader { scrollProxy in
                ScrollView {
                    VStack(alignment: .leading, spacing: 10) {
                        ForEach(messages) { message in
                            MessageView(
                                message: message,
                                messages: messages,
                                isNewMessage: $isNewMessage,
                                scrollOffset: $scrollOffset
                            )
                            .id(message.id)
                        }
                    }
                    .padding()
                }
                .onChange(of: isNewMessage) { newValue in
                    if newValue, let lastUserMessage = messages.last(where: { !$0.isAssistant }) {
                        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { // Wait for UI update
                            scrollProxy.scrollTo(lastUserMessage.id, anchor: .top) //  Move last user message to top
                        }
                    }
                }
            }
            
            // Input Field
            HStack {
                TextField("Type a message...", text: .constant(""))
                    .textFieldStyle(RoundedBorderTextFieldStyle())
                
                Button(action: sendMessage) {
                    Text("Send")
                        .padding()
                        .background(Color.blue)
                        .foregroundColor(.white)
                        .cornerRadius(10)
                }
            }
            .padding()
        }
    }
    
    private func sendMessage() {
        let newMessage = ChatMessage(text: "How can I create a smooth scrolling chat UI like ChatGPT?", isAssistant: false)
        messages.append(newMessage)
        isNewMessage = true // Trigger auto-scroll
        
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.3){
            let assistantMessage = ChatMessage(text: "Hold on, let me fetch the best answer for you!", isAssistant: true)
            messages.append(assistantMessage)
        }
    }
}

//MARK: - MessageView
struct MessageView: View {
    let message: ChatMessage
    let messages: [ChatMessage]
    @Binding var isNewMessage: Bool
    @Binding var scrollOffset: [UUID: CGFloat]
    @State private var safeAreaTop: CGFloat = 0
    
    var body: some View {
        ZStack(alignment: .topLeading) {
            RoundedRectangle(cornerRadius: 20)
                .foregroundColor(.white)
            
            HStack(alignment: .top) {
                if message.isAssistant {
                    MessageContentView(
                        message: message,
                        isNewMessage: $isNewMessage,
                        scrollOffset: $scrollOffset
                    )
                    .contentShape(Rectangle())

                    Spacer()
                } else {
                    Spacer()
                    MessageContentView(
                        message: message,
                        isNewMessage: $isNewMessage,
                        scrollOffset: $scrollOffset
                    )
                    .contentShape(Rectangle())
                }
            }
            .padding()
        }
        
        .background(GeometryReader { geometry in
            Color.clear
                .onAppear {
                    if safeAreaTop == 0 { // Capture safe area only once
                        let systemSafeArea = getSafeAreaTop()
                        let customNavBarHeight: CGFloat = 40
                        safeAreaTop = systemSafeArea + customNavBarHeight
                    }

                    let messageOffset = geometry.frame(in: .global).minY - safeAreaTop

                    if isNewMessage, isLastUserMessage(message, messages: messages) {
                        scrollOffset[message.id] = 0 // Force last user message to offset 0
                    } else {
                        scrollOffset[message.id] = max(0, messageOffset)
                    }
                }
                .onChange(of: geometry.frame(in: .global).minY) { newValue in
                    let messageOffset = newValue - safeAreaTop

                    if isNewMessage, isLastUserMessage(message, messages: messages) {
                        scrollOffset[message.id] = 0 // Keep last user message at offset 0
                    } else {
                        scrollOffset[message.id] = max(0, messageOffset)
                    }
                }
        })

    }
    
    ///  Check if the message is the last user message
    private func isLastUserMessage(_ message: ChatMessage, messages: [ChatMessage]) -> Bool {
        guard let lastUserMessage = messages.last(where: { !$0.isAssistant }) else {
            return false
        }
        return lastUserMessage.id == message.id
    }
    
    /// Get Safe Area Top
    private func getSafeAreaTop() -> CGFloat {
        return (UIApplication.shared.connectedScenes.first as? UIWindowScene)?
            .windows.first?.safeAreaInsets.top ?? 0
    }
    
}

//MARK: - MessageContentView
struct MessageContentView: View {
    let message: ChatMessage
    @Binding var isNewMessage: Bool
    @Binding var scrollOffset: [UUID: CGFloat]
    var body: some View {
        if message.isTyping {
            TypingIndicatorView()
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding(.top, 10)
        } else {
            VStack(alignment: .leading, spacing: 5){
                Text("Offset: \(scrollOffset[message.id] ?? 0, specifier: "%.2f")")
                    .font(.caption)
                    .foregroundColor(.gray)
                
                Text(message.text ?? "")
                    .font(.body)
                    .padding(10) // Add padding inside the bubble
                    .background(
                        RoundedRectangle(cornerRadius: 10) // Rounded corners
                            .fill(message.isAssistant ? Color.gray : Color("appPrimaryColor").opacity(0.7)) // Different colors for sender & receiver
                    )
                
                
                    .foregroundColor(Color("appTextColor"))
                    .lineLimit(nil)
                    .fixedSize(horizontal: false, vertical: true)
            }
            
        }
    }
}

struct ChatMessage: Identifiable {
    let id = UUID()
    let text: String
    let isAssistant: Bool
    var isTyping: Bool = false
}

#Preview{
    ChatView()
}


//MARK: - TypingIndicatorView
struct TypingIndicatorView: View {
    @State private var currentDot = 0
    private let dotCount = 3
    private let animationSpeed = 0.3 // Time between dots
    
    var body: some View {
        HStack(spacing: 4) {
            ForEach(0..<dotCount, id: \.self) { index in
                Circle()
                    .fill(index == currentDot ? Color.gray : Color.gray.opacity(0.5))
                    .frame(width: 8, height: 8)
            }
        }
        .onAppear {
            startTypingAnimation()
        }
    }
    
    private func startTypingAnimation() {
        Timer.scheduledTimer(withTimeInterval: animationSpeed, repeats: true) { timer in
            withAnimation {
                currentDot = (currentDot + 1) % dotCount
            }
        }
    }
}
解决方案

1. 核心修改思路

  • 移除冗余的手动偏移量跟踪,利用ScrollViewReader原生能力实现精准滚动
  • 添加滚动监听,区分用户主动滚动和自动滚动状态
  • 优化布局结构,消除额外间距对滚动锚点的影响

2. 修改后完整代码

import SwiftUI

// 自定义滚动偏移监听修饰符
extension View {
    func onScrollChanged(perform action: @escaping (CGFloat) -> Void) -> some View {
        self.background(
            GeometryReader { geo in
                Color.clear
                    .preference(key: ScrollOffsetKey.self, value: geo.frame(in: .named("scroll")).minY)
            }
        )
        .onPreferenceChange(ScrollOffsetKey.self) { value in
            action(value)
        }
    }
}

struct ScrollOffsetKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {}
}

struct ChatView: View {
    @State private var messages: [ChatMessage] = [
        ChatMessage(text: "Hello! How can I assist you today?", isAssistant: true),
        ChatMessage(text: "I need help with SwiftUI!", isAssistant: false),
        ChatMessage(text: "Sure! What do you need help with in SwiftUI?", isAssistant: true)
    ]
    @State private var isNewMessage: Bool = false
    @State private var scrollOffset: CGFloat = 0
    
    var body: some View {
        VStack(spacing: 0) {
            ScrollViewReader { scrollProxy in
                ScrollView {
                    GeometryReader { scrollGeo in
                        VStack(alignment: .leading, spacing: 10) {
                            ForEach(messages) { message in
                                MessageView(message: message)
                                    .id(message.id)
                            }
                        }
                        .padding()
                        .frame(minHeight: scrollGeo.size.height)
                    }
                }
                .coordinateSpace(name: "scroll")
                .onScrollChanged { offset in
                    // 滚动偏移超过阈值时,判定为用户主动滚动,取消新消息锁定
                    if isNewMessage && offset < -20 {
                        isNewMessage = false
                    }
                }
                .onChange(of: messages) { _ in
                    guard isNewMessage, let lastUserMessage = messages.last(where: { !$0.isAssistant }) else { return }
                    DispatchQueue.main.async {
                        scrollProxy.scrollTo(lastUserMessage.id, anchor: .top)
                    }
                }
            }
            
            // 输入栏
            HStack {
                TextField("Type a message...", text: .constant(""))
                    .textFieldStyle(RoundedBorderTextFieldStyle())
                
                Button(action: sendMessage) {
                    Text("Send")
                        .padding()
                        .background(Color.blue)
                        .foregroundColor(.white)
                        .cornerRadius(10)
                }
            }
            .padding()
        }
        .edgesIgnoringSafeArea(.bottom)
    }
    
    private func sendMessage() {
        let newMessage = ChatMessage(text: "How can I create a smooth scrolling chat UI like ChatGPT?", isAssistant: false)
        messages.append(newMessage)
        isNewMessage = true
        
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.3){
            let assistantMessage = ChatMessage(text: "Hold on, let me fetch the best answer for you!", isAssistant: true)
            messages.append(assistantMessage)
        }
    }
}

//MARK: - MessageView
struct MessageView: View {
    let message: ChatMessage
    
    var body: some View {
        HStack(alignment: .top, spacing: 0) {
            if message.isAssistant {
                MessageContentView(message: message)
                Spacer()
            } else {
                Spacer()
                MessageContentView(message: message)
            }
        }
    }
}

//MARK: - MessageContentView
struct MessageContentView: View {
    let message: ChatMessage
    var body: some View {
        if message.isTyping {
            TypingIndicatorView()
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding(.top, 10)
        } else {
            Text(message.text)
                .font(.body)
                .padding(12)
                .background(message.isAssistant ? Color.gray.opacity(0.2) : Color.blue.opacity(0.7))
                .foregroundColor(.white)
                .cornerRadius(16)
                .lineLimit(nil)
                .fixedSize(horizontal: false, vertical: true)
        }
    }
}

struct ChatMessage: Identifiable {
    let id = UUID()
    let text: String
    let isAssistant: Bool
    var isTyping: Bool = false
}

#Preview{
    ChatView()
}

//MARK: - TypingIndicatorView
struct TypingIndicatorView: View {
    @State private var currentDot = 0
    private let dotCount = 3
    private let animationSpeed = 0.3
    
    var body: some View {
        HStack(spacing: 4) {
            ForEach(0..<dotCount, id: \.self) { index in
                Circle()
                    .fill(index == currentDot ? Color.gray : Color.gray.opacity(0.5))
                    .frame(width: 8, height: 8)
                    .scaleEffect(index == currentDot ? 1.2 : 1)
            }
        }
        .onAppear {
            Timer.scheduledTimer(withTimeInterval: animationSpeed, repeats: true) { timer in
                withAnimation(.easeInOut) {
                    currentDot = (currentDot + 1) % dotCount
                }
            }
        }
    }
}

3. 修改说明

  • 移除冗余逻辑:删除了scrollOffset字典和MessageView中的几何监听代码,简化结构
  • 滚动状态切换:通过自定义onScrollChanged修饰符监听滚动偏移,当用户主动滚动时取消isNewMessage状态
  • 精准滚动触发:监听messages数组变化来执行滚动,确保UI更新完成后定位到最新用户消息顶部
  • 布局优化:用GeometryReader包裹消息列表,确保滚动区域高度适配屏幕,消除额外间距对锚点的影响

内容的提问来源于stack exchange,提问作者Maaz Siddiqui

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.14 03:29:51