swiftswiftuiswiftui-scrollview

How can I make elements appear at the bottom of a ScrollView?


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


Solution

  • 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.

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

    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:

    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:

    Text(chat.message)
        .transition(.push(from: .bottom))
    
    Button {
        withAnimation {
            viewModel.sendMessage(text: currentMessage)
        }
        currentMessage = ""
    } label: {
        // ...
    }