skip to Main Content

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


  1. 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:

    import SwiftUI
    
    struct ChatView: View {
        @ObservedObject var viewModel: ChatViewModel
        
        var body: some View {
            ScrollViewReader { proxy in
                ScrollView(showsIndicators: false) {
                    LazyVStack {
                        ForEach(viewModel.location.chat) { chat in
                            Text(chat.message)
                                .id(chat.id)
                                .padding()
                        }
                    }
                    .frame(maxWidth: .infinity)
                }
                .onAppear {
                    scrollToBottom(proxy: proxy)
                }
                .onChange(of: viewModel.location.chat) { _ in
                    scrollToBottom(proxy: proxy)
                }
            }
        }
        
        private func scrollToBottom(proxy: ScrollViewProxy) {
            if let lastMessage = viewModel.location.chat.last {
                withAnimation {
                    proxy.scrollTo(lastMessage.id, anchor: .bottom)
                }
            }
        }
    }
    
    class ChatViewModel: ObservableObject {
        @Published var location: ChatLocation
        
        init(location: ChatLocation) {
            self.location = location
        }
    }
    
    struct ChatLocation {
        var chat: [ChatMessage]
    }
    
    struct ChatMessage: Identifiable {
        let id: UUID
        let message: String
    }
    
    // Example usage with mock data
    struct ContentView: View {
        @StateObject var viewModel = ChatViewModel(location: ChatLocation(chat: [
            ChatMessage(id: UUID(), message: "Hello!"),
            ChatMessage(id: UUID(), message: "How are you?"),
            // Add more messages as needed
        ]))
        
        var body: some View {
            ChatView(viewModel: viewModel)
        }
    }
    

    Ensure your viewModel.location.chat is correctly identified as @Published so SwiftUI can react to changes.

    Use ScrollViewReader with onAppear and onChange to automatically scroll to the bottom whenever messages are added or the view appears.

    Login or Signup to reply.
  2. 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.

    • If I start with an empty list and send lots of messages, the view starts to scroll when it reaches the bottom of the screen and the latest message is always visible.
    • If I pre-load 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:

    1. If you want to pass in the 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")
    )
    
    1. In Location and StringChatMessage, use let for the id:
    let id = UUID()
    
    1. Since 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)
    }
    
    1. In the function 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)
            }
        }
    }
    
    1. Currently, the draft message is saved in 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: {
        // ...
    }
    
    1. As I was suggesting in a comment, you could use .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
            }
        }
    }
    
    1. If you supply 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)
            }
        }
    }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search