I am constructing a chat interface in SwiftUI much like ChatGPT’s app, and I would like the keyboard to have the same behaviour to iMessage when a consumer sorts the enter field goes above the keyboard, the chat scrolls and the header stays on the prime. However I am having points with keyboard dealing with. When the keyboard seems, it pushes all the view up as a substitute of simply adjusting the chat content material space whereas preserving the header fastened and enter discipline above the keyboard.
Present Habits
- When the keyboard seems, all the view (together with header) strikes up
- The enter discipline typically will get hidden behind the keyboard
- The scrollable content material space would not regulate correctly
Code
This is my present implementation for my ChatView
(I do even have a SideMenuView which appears to be pushed up too)
import SwiftUI
struct ChatView: View {
@State non-public var isMenuOpen = false
@State non-public var showSettings = false
@State non-public var showModelSelection = false
@State non-public var dragOffset: CGFloat = 0
@StateObject non-public var modelSelectionVM = ModelSelectionViewModel()
@StateObject non-public var chatHistoryVM = ChatHistoryViewModel()
@StateObject non-public var chatVM: ChatMessageViewModel
@FocusState non-public var isTextFieldFocused: Bool
non-public let menuWidth: CGFloat = UIScreen.fundamental.bounds.width * 0.75
init() {
let modelVM = ModelSelectionViewModel()
let historyVM = ChatHistoryViewModel()
_modelSelectionVM = StateObject(wrappedValue: modelVM)
_chatHistoryVM = StateObject(wrappedValue: historyVM)
_chatVM = StateObject(wrappedValue: ChatMessageViewModel(modelSelectionVM: modelVM, chatHistoryVM: historyVM))
}
var physique: some View {
ZStack(alignment: .main) {
Coloration(.systemBackground)
.ignoresSafeArea()
// Dim background when menu is open
if isMenuOpen {
Coloration.black.opacity(0.4)
.ignoresSafeArea()
.onTapGesture { closeMenu() }
}
// Facet Menu
SideMenuView(isMenuOpen: $isMenuOpen,
showSettings: $showSettings,
dragOffset: $dragOffset,
chatHistoryVM: chatHistoryVM,
chatVM: chatVM)
.offset(x: isMenuOpen ? 0 : -menuWidth)
VStack(spacing: 0) {
// **Fastened Header**
HStack {
Button(motion: toggleMenu) {
Picture(systemName: "line.horizontal.3")
.font(.title)
.foregroundColor(.blue)
}
.body(width: 44, peak: 44)
Spacer()
Button(motion: { showModelSelection = true }) {
Textual content(modelSelectionVM.selectedModel?.identify ?? "Choose Mannequin")
.foregroundColor(modelSelectionVM.selectedModel == nil ? .pink : .major)
}
Spacer()
Button(motion: {
if chatVM.messages.isEmpty {
chatVM.isTemporaryChat.toggle()
} else {
chatHistoryVM.selectedChatId = nil
chatVM.clearMessages()
}
}) {
Picture(systemName: chatVM.messages.isEmpty ?
(chatVM.isTemporaryChat ? "timer.circle.fill" : "timer.circle") :
"plus.circle.fill")
.font(.title)
.foregroundColor(chatVM.isTemporaryChat ? .orange : .blue)
}
.body(width: 44, peak: 44)
}
.padding(.horizontal)
.padding(.prime, 50)
.body(peak: 50) // Repair header peak
.background(Coloration(.systemBackground))
.zIndex(1) // Guarantee header stays fastened
// **Scrollable Messages**
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 8) {
if chatVM.isTemporaryChat && chatVM.messages.isEmpty {
Textual content("This chat will not seem in historical past, use or create recollections, or be used to coach our fashions. For security functions, we might make a copy of this chat for as much as 30 days.")
.foregroundColor(.secondary)
.multilineTextAlignment(.heart)
.padding()
}
ForEach(chatVM.messages) { message in
ChatMessageView(message: message)
.id(message.id)
}
}
.padding(.backside, 8)
}
.onChange(of: chatVM.messages.rely) { _ in
scrollToBottom(proxy: proxy)
}
}
.padding(.backside, 10)
// **Enter Area**
VStack(spacing: 0) {
Divider()
HStack(spacing: 16) {
TextField("Sort a message...", textual content: $chatVM.currentInput)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Coloration(.systemGray6))
.cornerRadius(20)
.centered($isTextFieldFocused)
.submitLabel(.ship)
.onSubmit {
chatVM.sendMessage()
}
Button(motion: {
chatVM.sendMessage()
})
.disabled(chatVM.currentInput.isEmpty || chatVM.isTyping)
}
.padding(.horizontal)
.padding(.vertical, 8)
}
.background(Coloration(.systemBackground))
.ignoresSafeArea(.keyboard, edges: .backside)
}
.body(width: UIScreen.fundamental.bounds.width)
.background(Coloration(.systemBackground))
.offset(x: dragOffset + (isMenuOpen ? menuWidth : 0))
}
.gesture(
DragGesture()
.onChanged { gesture in
let translation = gesture.translation.width
if isMenuOpen {
dragOffset = Swift.min(0, translation)
} else {
dragOffset = Swift.max(0, translation)
}
}
.onEnded { gesture in
let translation = gesture.translation.width
let velocity = gesture.velocity.width
withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) {
if isMenuOpen velocity > 500
else velocity > 500
dragOffset = 0
}
}
)
.overlay {
if showSettings {
SettingsView(isPresented: $showSettings)
.transition(.transfer(edge: .backside))
}
if showModelSelection {
ModelSelectionView(isPresented: $showModelSelection, viewModel: modelSelectionVM)
.transition(.transfer(edge: .backside))
}
}
}
Anticipated Habits
- Header ought to stay fastened on the prime
- Chat content material space ought to regulate its dimension and scroll when keyboard seems
- Enter discipline ought to keep seen and transfer up with the keyboard
- Just like how iMessage handles keyboard look
What I’ve Tried
- Utilizing
.ignoresSafeArea(.keyboard, edges: .backside)
- Including keyboard observers and manually adjusting view positions
- Utilizing GeometryReader to handle format
- Varied mixtures of ZStack and VStack
- Totally different approaches with safeAreaInsets
Setting
- iOS 17+
- SwiftUI
- Xcode 15.2
Query
How can I correctly implement keyboard dealing with on this chat interface to:
- Preserve the header fastened on the prime
- Enable the chat content material to regulate and scroll appropriately
- Preserve the enter discipline seen above the keyboard
- Keep a easy animation throughout keyboard look/disappearance
Any assist or steerage can be significantly appreciated!