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 = ""
}
}
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
:
init(location: Location) {
self.location = location
for i in 1...100 {
currentMessage = "Message \(i)"
sendMessage()
}
}
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
to ContentView
, then use @ObservedObject
instead of @StateObject
. Alternatively, keep it as a @StateObject
and create the model in ContentView
. In the latter case, it should also be private
:// ContentView
@StateObject private var viewModel = ViewModel(
location: Location(name: "Test location")
)
Location
and StringChatMessage
, use let
for the id
:let id = UUID()
StringChatMessage
implements Identifiable
, there is no need to set an .id
on the items in the ForEach
:ForEach(viewModel.location.chat) { chat in
Text(chat.message)
// .id(chat.id)
}
scrollToBottom
, there is no need to call .scrollTo
asynchronously. However, you might like to call it withAnimation
, so that the scrolling is animated:private func scrollToBottom(proxy: ScrollViewProxy) {
if let lastMessage = viewModel.location.chat.last {
withAnimation {
proxy.scrollTo(lastMessage.id, anchor: .bottom)
}
}
}
ViewModel
as currentMessage
and then sent when sendMessage
is called. But will there really be any other observers of the current message, apart from ContentView
? I would suggest making currentMessage
a @State
variable in ContentView
and passing the text of the message as parameter to sendMessage
:// ViewModel
// @Published var currentMessage: String = ""
func sendMessage(text: String) {
location.chat.append(StringChatMessage(isUser: true, message: text))
location.chat.append(StringChatMessage(isUser: false, message: "*reply*"))
}
// ContentView
@State private var currentMessage: String = ""
TextField("Message", text: $currentMessage)
// ... modifiers as before
Button {
viewModel.sendMessage(text: currentMessage)
currentMessage = ""
} label: {
// ...
}
.scrollPosition
in connection with .scrollTargetLayout
and remove the ScrollViewReader
altogether. This might be simpler, not least, because you don't need to pass the ScrollViewProxy
as parameter to scrollToBottom
:// ContentView
@State private var scrollPosition: UUID?
// ScrollViewReader { proxy in
ScrollView(showsIndicators: false) {
LazyVStack {
// ...
}
.scrollTargetLayout()
.frame(maxWidth: .infinity)
}
.scrollPosition(id: $scrollPosition, anchor: .bottom)
.onAppear {
scrollToBottom()
}
.onChange(of: viewModel.location.chat) {
scrollToBottom()
}
// }
private func scrollToBottom() {
if let lastMessage = viewModel.location.chat.last {
withAnimation {
scrollPosition = lastMessage.id
}
}
}
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 function scrollToBottom
too.Here is the fully-updated ContentView
, which works this way:
struct ContentView: View {
@StateObject private var viewModel = ViewModel(
location: Location(name: "Test location")
)
@State private var currentMessage: String = ""
@State private var scrollPosition: UUID?
var body: some View {
VStack {
ScrollView(showsIndicators: false) {
LazyVStack {
ForEach(viewModel.location.chat) { chat in
Text(chat.message)
}
}
.scrollTargetLayout()
.frame(maxWidth: .infinity)
}
.scrollPosition(id: $scrollPosition, anchor: .bottom)
.onChange(of: viewModel.location.chat, initial: true) { oldVal, newVal in
if let lastMessage = newVal.last {
withAnimation {
scrollPosition = lastMessage.id
}
}
}
HStack {
TextField("Message", text: $currentMessage)
.frame(height: 40)
.padding([.leading, .trailing], 12)
.overlay {
RoundedRectangle(cornerRadius: 20)
.stroke(Color.black.opacity(0.4))
}
Button {
viewModel.sendMessage(text: currentMessage)
currentMessage = ""
} label: {
Image(systemName: "paperplane.fill")
.foregroundStyle(Color.white)
.frame(width: 40, height: 40)
.background(Color.accentColor)
.clipShape(Circle())
}
}
.padding([.leading, .trailing, .bottom], 12)
}
}
}
EDIT In your comment, you explained that you want the content inside the ScrollView
to start at the bottom instead of at the top. To get it working this way:
ScrollView
with a GeometryReader
, to measure the space being usedScrollView
as the minHeight
on the LazyVStack
, with alignment of .bottom
.GeometryReader { proxy in
ScrollView(showsIndicators: false) {
LazyVStack {
// ...
}
.scrollTargetLayout()
.frame(maxWidth: .infinity, minHeight: proxy.size.height, alignment: .bottom)
}
// ...other modifiers as before
}
EDIT2 Regarding animation - if I understand correctly, you want a new message to be added to the bottom of an empty ScrollView
with some kind of push animation from the bottom, right? You could try these changes:
.transition
modifier to the Text
showing a message:Text(chat.message)
.transition(.push(from: .bottom))
sendMessage
with withAnimation
:Button {
withAnimation {
viewModel.sendMessage(text: currentMessage)
}
currentMessage = ""
} label: {
// ...
}