2

I have a chat app, where whenever a chat room is opened, I need the view to scroll to the bottom as soon as the messages are fetched.

The thing is that although it does scroll perfectly when a new message is received or sent (see ViewModel down below), it is very jittery when I tell it to scroll right after the first batch of messages is fetched, which happens once as soon as the view appears.

After a lot of trial and error, I realized that if I add a small delay to the scroll, it'll improve but not completely! It is like it's trying to scroll to the very bottom, but it'll fail just for a few inches. I also realized that if I add a bigger delay, like 2 seconds, it'll scroll just fine.

Here's the messages list view:

    struct MessagesView: View {
        @StateObject private var viewModel = ViewModel()
        
        // -----------------------
        
        let currentChatRoom: ChatRoom
        
        // -----------------------
        
        var body: some View {
            ZStack {
                Color.black.ignoresSafeArea()
                
                VStack {
                    ScrollView {
                        ScrollViewReader { proxy in
                            LazyVStack {
                                ForEach(viewModel.messages) { message in
                                    MessageView(message: message)
                                        .id(message.id)
                                        .onTapGesture {
                                            viewModel.shouldDismissKeyboard = true
                                        }
                                }
                            }
                            .onChange(of: viewModel.shouldScrollToMessageId) { messageId in
                                if let messageId = messageId, !messageId.isEmpty {
                                    proxy.scrollTo(messageId, anchor: .bottom)
                                }
                            }
                        }
                    }
                    
                    VStack(alignment: .leading) {
                        if chatEnvironment.isOtherUserTyping {
                            TypingIndicationView()
                        }
                        
                        BottomView()
                            .padding(.bottom, 4)
                    }
                }
            }
            .onAppear {
                viewModel.setUp(currentChatRoom: currentChatRoom)
            }
        }
    }

As you can see, it’s viewModel.shouldScrollToMessageId that’s responsible for "auto-scrolling" to the last message.

Here’s MessageView:

    fileprivate struct MessageView: View {
        let message: Message
        
        var body: some View {
            HStack {
                VStack(alignment: .leading, spacing: 1) {
                    Text(message.user.isCurrentUser == true ? "You" : "\(message.user.username)")
                        .foregroundColor(message.user.isCurrentUser == true ? .customGreen : .customBlue)
                        .multilineTextAlignment(.leading)
                        .font(.default(size: 16))
                        .padding(.bottom, 1)
                    
                    if let imageURL = message.postSource?.imageURL, !imageURL.isEmpty {
                        VStack(alignment: .leading) {
                            WebImage(url: .init(string: imageURL))
                                .resizable()
                                .indicator(.activity)
                                .scaledToFill()
                                .frame(width: UIScreen.main.bounds.width / 2, height: UIScreen.main.bounds.width / 1.45)
                                .cornerRadius(25)
                        }
                    }
                    
                    Text(message.text)
                        .foregroundColor(.white)
                        .multilineTextAlignment(.leading)
                        .font(.default(size: 16))
                }
                
                Spacer()
            }
            .padding(.bottom, 8)
            .padding(.horizontal)
            .background(
                Color.black
            )
        }
    }

Here’s the ViewModel:

    class ViewModel: ObservableObject {
            @Published var messages = [Message]()
            @Published var text = ""
            @Published var shouldScrollToMessageId: String?
            @Published var currentChatRoom: ChatRoom?
            
            // -----------------------------
            
            private var isInitialized = false
            
            // -----------------------------
            
            func setUp(currentChatRoom: ChatRoom) {
                guard !isInitialized else { return }
                isInitialized.toggle()
                
                // -----------------------------
                
                self.currentChatRoom = currentChatRoom
                
                // -----------------------------
                
                getFirstBatchOfMessages(chatRoom: chatRoom)
                subscribeToNewMessages()
            }
            
            private func getFirstBatchOfMessages(chatRoom: ChatRoom) {
                messagesService.getMessages(chatRoomId: chatRoom.id) { [weak self] messages in
                    DispatchQueue.main.async {
                        self?.messages = messages
                    }
                    
                    self?.scrollToBottom(delay: 0.15)
                }
            }
    
            private func subscribeToNewMessages() {
                ...
                    
                if !newMessages.isEmpty {
                    self?.scrollToBottom(delay: 0)
                }
            }
            
            func scrollToBottom(delay: TimeInterval) {
                DispatchQueue.main.async {
                    self.shouldScrollToMessageId = self.messages.last?.id
                }
            }
            
            func sendMessage() {
                ...
    
                scrollToBottom(delay: 0)
            }
        }

Here, scrollToBottom is responsible for notifying the MessagesView that shouldScrollToMessageId's value changed and that it should scroll to the last message.

Any help will be much appreciated!!!

Ken White
  • 123,280
  • 14
  • 225
  • 444
Sotiris Kaniras
  • 520
  • 1
  • 12
  • 30

1 Answers1

2

I am also writing an application with a chat on SwiftUI and I also have a lot of headaches with a bunch of ScrollView and LazyVStack.

In my case, I load messages from the CoreData and display them in a LazyVStack, so in my case, scrolling to the last message does not work, it seems to me simply because a bottom last view did not render, because rendering starts from the top.

Therefore, I came up with a solution with placing an invisible view at the bottom and assigned it a static ID, in my case -1:


VStack(spacing: 0) {
    LazyVStack(spacing: 0) {
        ForEach(messages) { message in
            MessageRowView(viewWidth: wholeViewProxy.size.width, message: message)
                .equatable()
        }
    }
                        
    Color.clear.id(-1)
        .padding(.bottom, inputViewHeight)
}

I call scroll to this view:

.onAppear {
    scrollTo(messageID: -1, animation: nil, scrollReader: scrollReader)
}

And it works... but sometimes...

Sometimes it works correctly, sometimes it stops without scrolling a couple of screens to the end. Looks like LazyVStack is rendering the next views after the scrollTo has finished its work. Also, if add scrolling with some delay it may works better, but not always perfect.

I hope this can be a little helpful and if you find a stable solution I will be very happy if you share :)