iosswiftuichatbotswiftui-listchat-gpt-4

Chatbot UI Like ChatGPT in iOS SwiftUI


I am working on a chatbot app and want to implement a chat interface similar to ChatGPT's UI. When a user sends a message:

Additionally, when the user scrolls down, previous messages should load naturally—without any animation glitches or abrupt jumps.

Here is my current code:

In this implementation, I am fetching the message bubble's offset and checking if isNewMessage is true. If it is, I attempt to set the last "user" message bubble's offset to 0. However, I am unable to position it exactly at the top of the screen.

My goal:

Currently, I am struggling to properly set the last message to the exact top offset.

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
            }
        }
    }
}


Solution

  • If I understand correctly, the main requirement is that you want a new question from the user to appear at the top of the scroll view, the reply from the assistant should then appear in the space below it.

    This means, you need to add blank space between the user’s question and the bottom of the scroll view.

    Suggested changes

    Organizing the data

    One way to approach the problem is to combine a message from the user with the responses from the assistant as a single “group” entity.

    Assuming that there can be 0 or 1 messages from the user which are followed by n messages from the assistant, the following struct can be used to group them:

    struct MessageGroup: Identifiable {
        let id = UUID()
        let userMessage: ChatMessage?
        var assistantMessages = [ChatMessage]()
    }
    

    The view can now be changed to show these message groups, instead of individual messages. This makes the positioning simpler, as will be seen below.

    Positioning

    Scrolled positioning can be implemented as follows:

    Doing it this way, you no longer need any of the position tracking that you had in your previous solution. You also don’t need the flag for a new message, because the latest message group will always be the last group in the array.

    Safe-area inset

    Normally, if a ScrollView is in contact with the safe area inset at the top of the screen then the content of the scroll view will pass through this safe area. This means, the end of the previous message group will be seen above a new message.

    You said in the question that the previous messages should move out of view at the top. So if you don’t want the end of the last message to be visible, add top padding of 1 pixel to break the contact with the safe area inset. The environment value pixelLength gives you the size of 1 pixel in points.

    View simplifications

    MessageView and MessageContentView can be simplified to the following:

    //MARK: - MessageView
    struct MessageView: View {
        let message: ChatMessage
    
        var body: some View {
            MessageContentView(message: message)
                .frame(maxWidth: .infinity, alignment: message.isAssistant ? .leading : .trailing)
                .padding()
                .background(.white, in: .rect(cornerRadius: 20))
        }
    }
    
    //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 {
                VStack(alignment: .leading, spacing: 5){
                    Text("(Timestamp)")
                        .font(.caption)
                        .foregroundStyle(.gray)
    
                    Text(message.text)
                        .font(.body)
                        .padding(10)
                        .background(
                            message.isAssistant ? .gray.opacity(0.5) : .purple.opacity(0.7), // Color("appPrimaryColor")
                            in: .rect(cornerRadius: 10)
                        )
                        // .foregroundStyle(Color("appTextColor"))
                        .fixedSize(horizontal: false, vertical: true)
                }
            }
        }
    }
    

    Putting it all together

    The part still missing is the programmatic scrolling. There would be different ways to do this, depending on which iOS version you need to support.

    iOS 17 and above

    Here is the updated example to show it working this way:

    struct ChatView: View {
        @Environment(\.pixelLength) private var pixelLength
        @State private var messageGroups: [MessageGroup] = [
            MessageGroup(
                userMessage: nil,
                assistantMessages: [ChatMessage(text: "Hello! How can I assist you today?", isAssistant: true)]
            ),
            MessageGroup(
                userMessage: ChatMessage(text: "I need help with SwiftUI!", isAssistant: false),
                assistantMessages: [ChatMessage(text: "Sure! What do you need help with in SwiftUI?", isAssistant: true)]
            )
        ]
        @State private var scrollPosition: UUID?
    
        var body: some View {
            VStack {
                GeometryReader { geoProxy in
                    let scrollViewHeight = geoProxy.size.height
                    ScrollView {
                        VStack(spacing: 10) {
                            ForEach(messageGroups) { group in
                                VStack(spacing: 10) {
                                    if let message = group.userMessage {
                                        MessageView(message: message)
                                    }
                                    ForEach(group.assistantMessages) { message in
                                        MessageView(message: message)
                                    }
                                }
                                .frame(
                                    minHeight: group.id == messageGroups.last?.id ? scrollViewHeight : nil,
                                    alignment: .top
                                )
                            }
                        }
                        .scrollTargetLayout()
                        .padding(.horizontal)
                    }
                    .scrollPosition(id: $scrollPosition, anchor: .top)
                }
                .padding(.top, pixelLength) // Break contact with the safe area inset
    
                // Input Field
                HStack {
                    TextField("Type a message...", text: .constant(""))
                        .textFieldStyle(.roundedBorder)
    
                    Button("Send", action: sendMessage)
                        .buttonStyle(.borderedProminent)
                }
                .padding()
            }
        }
    
        private func sendMessage() {
            let newMessage = ChatMessage(text: "How can I create a smooth scrolling chat UI like ChatGPT?", isAssistant: false)
            let group = MessageGroup(userMessage: newMessage)
            messageGroups.append(group)
            withAnimation {
                scrollPosition = group.id
            }
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                let assistantMessage = ChatMessage(text: "Hold on, let me fetch the best answer for you!", isAssistant: true)
                messageGroups[messageGroups.count - 1].assistantMessages.append(assistantMessage)
            }
        }
    }
    

    Pre iOS 17

    If you want to support older versions of iOS then programmatic scrolling can be performed using a ScrollViewReader, like you were doing before.

    Btw, I found it was important to unwrap the optional inside the onChange handler, otherwise .scrollTo didn’t work.

    GeometryReader { geoProxy in
        let scrollViewHeight = geoProxy.size.height
        ScrollViewReader { scrollProxy in
            ScrollView {
                VStack(spacing: 10) {
                    // …
                }
                .padding(.horizontal)
    
                // Deprecated modifier used intentionally: targeting iOS < 17
                .onChange(of: messageGroups.last?.id) { lastId in
                    if let lastId {
                        withAnimation {
                            scrollProxy.scrollTo(lastId, anchor: .top)
                        }
                    }
                }
            }
        }
    }
    .padding(.top, pixelLength) // Break contact with the safe area inset
    
    private func sendMessage() {
        let newMessage = ChatMessage(text: "How can I create a smooth scrolling chat UI like ChatGPT?", isAssistant: false)
        let group = MessageGroup(userMessage: newMessage)
        messageGroups.append(group)
    
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
            let assistantMessage = ChatMessage(text: "Hold on, let me fetch the best answer for you!", isAssistant: true)
            messageGroups[messageGroups.count - 1].assistantMessages.append(assistantMessage)
        }
    }
    

    Both techniques work the same:

    Animation