SwiftUI: Chat like scrolling and keyboard behaviour

51 Views Asked by At

I am trying to build my own ChatGPT conversation app (SwiftUI with SwiftData, deployment target iOS 17.0) and am struggling a lot with the scrolling and keyboard behaviour in my ChatView. Basically I want to achieve a smooth user experience like in WhatsApp or Apple's Messages:

  • Opening the ChatView will scroll to the very bottom of the last message
  • Opening the keyboard (focus on TextField) will immediately push the whole content upwards (no delays)
  • Appending new text (will be either entered as a user message by the user or streamed chunk by chunk for OpenAI API) will smoothly scroll the view up so every new text is visible
  • When sending a message the keyboard should stay up and focused for an immediate reply

I played a around a lot with scrollTo and Scroll Targets but I just haven't found a 100% working solution yet. You can find my simplified View below, I removed all functionalities to start from scratch. Dummy messages can be added to simulate the behaviour.

import SwiftUI

struct Message: Codable, Identifiable {
    var id: String
    var body: String
}

struct DetailView: View {
    @State private var text: String = ""
    @State private var messages: [Message] = []
    
    var body: some View {
        VStack {
            ScrollViewReader { proxy in
                ScrollView {
                    LazyVStack {
                        ForEach(messages, id: \.id) { message in
                            HStack {
                                VStack {
                                    Image(systemName: "person.circle")
                                        .foregroundColor(.secondary)
                                    
                                    Spacer()
                                }
                                
                                VStack {
                                    Text(message.body)
                                    
                                    Spacer()
                                }
                                
                                Spacer()
                            }
                            .padding([.leading, .trailing, .top])
                            .id(message.id)
                        }
                    }
                }
            }
            
            HStack {
                TextField("New message", text: $text, axis: .vertical)
                    .font(.system(size: 14.0))
                    .lineLimit(10)
                    .padding([.trailing, .leading], 10)
                    .padding([.top, .bottom], 10)
                    .background(
                        RoundedRectangle(cornerRadius: 20)
                            .stroke(Color.secondary, lineWidth: 1)
                    )
                    .clipShape(RoundedRectangle(cornerRadius: 20))
                
                Button(action: {
                    addMessage()
                }) {
                    Image(systemName: "arrow.up.circle.fill")
                        .font(.system(size: 30))
                }
            }
            .padding([.leading, .trailing])
            .padding(.bottom, 10)
            .padding(.top, 1)
        }
        .navigationTitle("Messages")
        .navigationBarTitleDisplayMode(.inline)
    }
    
    private func addMessage() {
        let newMessage = Message(id: UUID().uuidString, body: "Officia deserunt exercitation aute enim do laborum qui magna elit dolore quis. Labore nulla ut reprehenderit in esse fugiat mollit minim. Magna ad aute dolor nostrud proident eiusmod labore dolor excepteur id consequat in. Aute dolore deserunt do Lorem occaecat eu. Fugiat exercitation aliqua voluptate enim id fugiat sint do aliquip duis quis ad.")
        
        messages.append(newMessage)
    }
}

I am hoping there is a very simple solution to this, I am just not finding it. Any help very much appreciated. So far Google could not help, I even asked my own app to help with no success...

Played around with focus states and State variables

@FocusState private var isTextFieldFocused: Bool
@State private var isTriggeringScroll: Bool = false
TextField("New message", text: $newAssistantMessage, axis: .vertical)
                    .font(.system(size: 14.0))
                    .lineLimit(10)
                    .focused($isTextFieldFocused)
.scrollDismissesKeyboard(.interactively)
                .onAppear {
                    if assistant.messages.count == 0 {
                        isTextFieldFocused = true
                    }
                    if let lastId = assistant.messages.sorted(by: { $0.date < $1.date }).last?.id {
                        DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
                            withAnimation {
                                proxy.scrollTo(lastId, anchor: .bottom)
                            }
                        }
                    }
                }
                .onChange(of: isTriggeringScroll) {
                    if let lastId = assistant.messages.sorted(by: { $0.date < $1.date }).last?.id {
                        proxy.scrollTo(lastId, anchor: .bottom)
                    }
                }
                .onChange(of: newAssistantMessage) {
                    if let lastId = assistant.messages.sorted(by: { $0.date < $1.date }).last?.id {
                        proxy.scrollTo(lastId, anchor: .bottom)
                    }
                }
                .onChange(of: isTextFieldFocused) {
                    if isTextFieldFocused {
                        DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
                            if let lastId = assistant.messages.sorted(by: { $0.date < $1.date }).last?.id {
                                withAnimation {
                                    proxy.scrollTo(lastId, anchor: .bottom)
                                }
                            }
                        }
                    }
                }

It is kind of working, but very buggy and not reliable. Also with delays and animations, jsut does not look acceptable to me.

0

There are 0 best solutions below