I'm building a chat interface in SwiftUI similar to ChatGPT's app, and I want the keyboard to have a similar behaviour to iMessage when a user types the input box goes above the keyboard, the chat scrolls and the header stays at the top. But I'm having issues with keyboard handling. When the keyboard appears, it pushes the entire view up instead of just adjusting the chat content area while keeping the header fixed and input field above the keyboard.
Current Behavior
- When the keyboard appears, the entire view (including header) moves up
- The input field sometimes gets hidden behind the keyboard
- The scrollable content area doesn't adjust properly
Code
Here's my current implementation for my ChatView (I do also have a SideMenuView which seems to be pushed up too)
import SwiftUI
struct ChatView: View {
@State private var isMenuOpen = false
@State private var showSettings = false
@State private var showModelSelection = false
@State private var dragOffset: CGFloat = 0
@StateObject private var modelSelectionVM = ModelSelectionViewModel()
@StateObject private var chatHistoryVM = ChatHistoryViewModel()
@StateObject private var chatVM: ChatMessageViewModel
@FocusState private var isTextFieldFocused: Bool
private let menuWidth: CGFloat = UIScreen.main.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 body: some View {
ZStack(alignment: .leading) {
Color(.systemBackground)
.ignoresSafeArea()
// Dim background when menu is open
if isMenuOpen {
Color.black.opacity(0.4)
.ignoresSafeArea()
.onTapGesture { closeMenu() }
}
// Side Menu
SideMenuView(isMenuOpen: $isMenuOpen,
showSettings: $showSettings,
dragOffset: $dragOffset,
chatHistoryVM: chatHistoryVM,
chatVM: chatVM)
.offset(x: isMenuOpen ? 0 : -menuWidth)
VStack(spacing: 0) {
// **Fixed Header**
HStack {
Button(action: toggleMenu) {
Image(systemName: "line.horizontal.3")
.font(.title)
.foregroundColor(.blue)
}
.frame(width: 44, height: 44)
Spacer()
Button(action: { showModelSelection = true }) {
Text(modelSelectionVM.selectedModel?.name ?? "Select Model")
.foregroundColor(modelSelectionVM.selectedModel == nil ? .red : .primary)
}
Spacer()
Button(action: {
if chatVM.messages.isEmpty {
chatVM.isTemporaryChat.toggle()
} else {
chatHistoryVM.selectedChatId = nil
chatVM.clearMessages()
}
}) {
Image(systemName: chatVM.messages.isEmpty ?
(chatVM.isTemporaryChat ? "timer.circle.fill" : "timer.circle") :
"plus.circle.fill")
.font(.title)
.foregroundColor(chatVM.isTemporaryChat ? .orange : .blue)
}
.frame(width: 44, height: 44)
}
.padding(.horizontal)
.padding(.top, 50)
.frame(height: 50) // Fix header height
.background(Color(.systemBackground))
.zIndex(1) // Ensure header stays fixed
// **Scrollable Messages**
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 8) {
if chatVM.isTemporaryChat && chatVM.messages.isEmpty {
Text("This chat won't appear in history, use or create memories, or be used to train our models. For safety purposes, we may keep a copy of this chat for up to 30 days.")
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding()
}
ForEach(chatVM.messages) { message in
ChatMessageView(message: message)
.id(message.id)
}
}
.padding(.bottom, 8)
}
.onChange(of: chatVM.messages.count) { _ in
scrollToBottom(proxy: proxy)
}
}
.padding(.bottom, 10)
// **Input Field**
VStack(spacing: 0) {
Divider()
HStack(spacing: 16) {
TextField("Type a message...", text: $chatVM.currentInput)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color(.systemGray6))
.cornerRadius(20)
.focused($isTextFieldFocused)
.submitLabel(.send)
.onSubmit {
chatVM.sendMessage()
}
Button(action: {
chatVM.sendMessage()
}) {
Image(systemName: "arrow.up.circle.fill")
.font(.system(size: 30))
.foregroundColor(chatVM.currentInput.isEmpty || chatVM.isTyping ? .gray : .blue)
}
.disabled(chatVM.currentInput.isEmpty || chatVM.isTyping)
}
.padding(.horizontal)
.padding(.vertical, 8)
}
.background(Color(.systemBackground))
.ignoresSafeArea(.keyboard, edges: .bottom)
}
.frame(width: UIScreen.main.bounds.width)
.background(Color(.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 {
isMenuOpen = translation > -menuWidth / 2 || velocity > 500
} else {
isMenuOpen = translation > menuWidth / 2 || velocity > 500
}
dragOffset = 0
}
}
)
.overlay {
if showSettings {
SettingsView(isPresented: $showSettings)
.transition(.move(edge: .bottom))
}
if showModelSelection {
ModelSelectionView(isPresented: $showModelSelection, viewModel: modelSelectionVM)
.transition(.move(edge: .bottom))
}
}
}
Expected Behavior
- Header should remain fixed at the top
- Chat content area should adjust its size and scroll when keyboard appears
- Input field should stay visible and move up with the keyboard
- Similar to how iMessage handles keyboard appearance
What I've Tried
- Using
.ignoresSafeArea(.keyboard, edges: .bottom)
- Adding keyboard observers and manually adjusting view positions
- Using GeometryReader to manage layout
- Various combinations of ZStack and VStack
- Different approaches with safeAreaInsets
Environment
- iOS 17+
- SwiftUI
- Xcode 15.2
Question
How can I properly implement keyboard handling in this chat interface to:
- Keep the header fixed at the top
- Allow the chat content to adjust and scroll appropriately
- Keep the input field visible above the keyboard
- Maintain a smooth animation during keyboard appearance/disappearance
Any help or guidance would be greatly appreciated!