1

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:

  • The previous messages should move out of view at the top.
  • The user's latest message should appear at the top.
  • The assistant's response should appear below it.

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:

  • When isNewMessage is true, the last user message in the messages array should appear at the top of the screen, with no previous messages visible.
  • Once the user starts scrolling, isNewMessage should turn false, and the scroll view should behave normally.

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

5
  • And what is the actual question/problem? Commented Mar 5 at 10:58
  • @BenzyNeez I am sharing the full implementation of my code, including all necessary details about the issue I’m facing and my current progress. Commented Mar 6 at 10:06
  • 1
    @JoakimDanielson I am sharing the full implementation of my code, including all necessary details about the issue I’m facing and my current progress. Commented Mar 7 at 5:57
  • @BenzyNeez Thanks! I am targeting a minimum of iOS 16, but I have no issue using iOS 17 if required. Commented Mar 7 at 5:59
  • @MaazSiddiqui I’m trying to implement the exact same behavior for my chatbot UI and have run into similar issues. I did experiment with a few different approaches so far and none of them works so far. I tried, - Manual height calculation in UIKit. - Using self-resizing cells with dynamic constraints for last cell. - Setting a minimum height for List / LazyVStack in SwiftUI. Have you managed to solve this on your end, or are you still facing the same issue? Commented Oct 7 at 10:00

1 Answer 1

1

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:

  • Wrap the ScrollView in a GeometryReader, so that the height of the ScrollView can be measured.
  • Set the minimum height of the last message group to the full height of the ScrollView. This way, blank space is shown below the latest message in the group, filling the area to the bottom of the scroll view.
  • Finally, when adding a new message group to the array, perform a programmatic scroll to this group.

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

  • Programmatic scrolling can use .scrollPosition in connection with .scrollTargetLayout.
  • Whenever a new message group is added to the array, set the id of the new group as the scroll position target. This can be done withAnimation.

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.

  • Use an onChange handler to detect when a new message group has been added to the array.
  • Scroll to the latest message group using withAnimation.

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

Sign up to request clarification or add additional context in comments.

2 Comments

Thank you for your code it has helped me creating a similar UI. I have one issue however, which your code snippet also seems to have, which I can't solve. If one set of messages is visible with other sets above it out of view and I open the keyboard, the scrollview will be dragged down. I would like the scroll view to stay the same and still only one set of messages be visible. Do you have a possible solution for this?
Pleased to have been able to help, thanks for accepting the answer (it was a long time ago). Unfortunately, I couldn't find a quick fix for the scroll issue. I think it may be being caused by the minHeight being applied inside the ForEach, so scrollViewHeight might need a "keyboard correction". Alternatively, you could try building the view a different way, for example, by showing the text field as an .overlay or .safeAreaInset. If you need help to get it working, I would suggest creating a new question. It should include a fully-functional MRE to demonstrate the issue.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.