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
}
}
}
}
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.
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:
ScrollView
in a GeometryReader
, so that the height of the ScrollView
can be measured.ScrollView
. This way, blank space is shown below the latest message in the group, filling the area to the bottom of the scroll view.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)
}
}
}
}
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
.scrollPosition
in connection with .scrollTargetLayout
.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.
onChange
handler to detect when a new message group has been added to the array.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: