I have a SwiftUI related question. I’m trying to make a chat and make messages appear at the bottom of the screen (like at the screen from Telegram). All solutions I tried keep rendering messages at the top. Documentation and ChatGPT couldn’t solve my problem.
The thing I tried the first was to use defaultScrollAnchor(_ anchor:)
modifier to ScrollView
. That didn’t work and moreover I didn’t notice any changes using this modifier. Neither .top
, .center
, or .top
parameters didn’t change anything. I tried both on static and dynamic list of elements in ScrollView
. My code inside the ScrollView
looks like that:
ScrollView(showsIndicators: false) {
LazyVStack {
ForEach(viewModel.location.chat) { chat in
Text(chat.message)
.id(chat.id)
}
}
.frame(maxWidth: .infinity)
}
Then I tried to scroll to the bottom using onAppear{}
and onChange{}
modifiers with help of ScrollViewReader
and scrollTo(_ id:anchor:)
. That didn’t work either. As a last resort I’m asking this question here. Spacer()
didn’t work either. Here is my complete code for the last implementation:
ScrollViewReader { proxy in
ScrollView(showsIndicators: false) {
LazyVStack {
ForEach(viewModel.location.chat) { chat in
Text(chat.message)
.id(chat.id)
}
}
.frame(maxWidth: .infinity)
}
.onAppear {
scrollToBottom(proxy: proxy)
}
.onChange(of: viewModel.location.chat) {
scrollToBottom(proxy: proxy)
}
}
And scrollToBottom
function:
private func scrollToBottom(proxy: ScrollViewProxy) {
DispatchQueue.main.async {
if let lastMessage = viewModel.location.chat.last {
proxy.scrollTo(lastMessage.id, anchor: .bottom)
}
}
}
Here I isolated the logic of the code keeping the structure:
struct ContentView: View {
@StateObject var viewModel: ViewModel
var body: some View {
VStack {
ScrollViewReader { proxy in
ScrollView(showsIndicators: false) {
LazyVStack {
ForEach(viewModel.location.chat) { chat in
Text(chat.message)
.id(chat.id)
}
}
.frame(maxWidth: .infinity)
}
.onAppear {
scrollToBottom(proxy: proxy)
}
.onChange(of: viewModel.location.chat) {
scrollToBottom(proxy: proxy)
}
}
HStack {
TextField("Message", text: $viewModel.currentMessage)
.frame(height: 40)
.padding([.leading, .trailing], 12)
.overlay {
RoundedRectangle(cornerRadius: 20)
.stroke(Color.black.opacity(0.4))
}
Button {
viewModel.sendMessage()
} label: {
Image(systemName: "paperplane.fill")
.foregroundStyle(Color.white)
.frame(width: 40, height: 40)
.background(Color.accentColor)
.clipShape(Circle())
}
}
.padding([.leading, .trailing, .bottom], 12)
}
}
private func scrollToBottom(proxy: ScrollViewProxy) {
DispatchQueue.main.async {
if let lastMessage = viewModel.location.chat.last {
proxy.scrollTo(lastMessage.id, anchor: .bottom)
}
}
}
}
#Preview {
ContentView(viewModel: ViewModel(location: Location(name: "Test locaiton")))
}
struct Location: Identifiable {
var id = UUID()
var name: String
var chat: [StringChatMessage]
init(name: String) {
self.name = name
self.chat = []
}
}
struct StringChatMessage: Identifiable, Equatable {
var id = UUID()
var isUser: Bool
var message: String
}
@MainActor
class ViewModel: ObservableObject {
@Published var location: Location
@Published var currentMessage: String = ""
init(location: Location) {
self.location = location
}
func sendMessage() {
guard !currentMessage.isEmpty else { return }
location.chat.append(StringChatMessage(isUser: true, message: currentMessage))
location.chat.append(StringChatMessage(isUser: false, message: "*reply*"))
currentMessage = ""
}
}
2
Answers
To make chat messages appear at the bottom of the screen in a SwiftUI chat application, you can use
ScrollViewReader
to control the scroll position programmatically. Here is a working solution:Ensure your
viewModel.location.chat
is correctly identified as@Published
so SwiftUI can react to changes.Use
ScrollViewReader
withonAppear
andonChange
to automatically scroll to the bottom whenever messages are added or the view appears.Thanks for updating the post to include more code.
When I try the code on an iPhone 15 simulator running iOS 17.5 (Xcode 15.4) it works fine for me.
ViewModel
with a bunch of messages, it scrolls to the last message on launch.To pre-load the messages, I updated
ViewModel.init
:So I don’t really know, why it is not working for you. Still, there are a few things I would suggest that you change. I don’t really think the changes will help resolve the issue (which I can’t reproduce), but they might help to improve the code a little:
viewModel
toContentView
, then use@ObservedObject
instead of@StateObject
. Alternatively, keep it as a@StateObject
and create the model inContentView
. In the latter case, it should also beprivate
:Location
andStringChatMessage
, uselet
for theid
:StringChatMessage
implementsIdentifiable
, there is no need to set an.id
on the items in theForEach
:scrollToBottom
, there is no need to call.scrollTo
asynchronously. However, you might like to call itwithAnimation
, so that the scrolling is animated:ViewModel
ascurrentMessage
and then sent whensendMessage
is called. But will there really be any other observers of the current message, apart fromContentView
? I would suggest makingcurrentMessage
a@State
variable inContentView
and passing the text of the message as parameter tosendMessage
:.scrollPosition
in connection with.scrollTargetLayout
and remove theScrollViewReader
altogether. This might be simpler, not least, because you don’t need to pass theScrollViewProxy
as parameter toscrollToBottom
:initial: true
to the.onChange
modifier then you could drop the.onAppear
callback. You could also get the last message from the updated array that is passed to the closure, which means you could do all the automated scrolling in the.onChange
callback and drop the functionscrollToBottom
too.Here is the fully-updated
ContentView
, which works this way: