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
相关产品推荐
相关产品推荐

