Build An iMessage-Style Chat Input In Pure SwiftUI
A chat input looks like one tiny control, but it sits at the most annoying intersection in the app: a scrolling list, the keyboard, focus, dynamic type, safe areas, and a send button that should never jump around.
The good news is that you do not need a UIKit wrapper for the common version. A ScrollView with a LazyVStack, a bottom safeAreaInset, and a focused growing TextField gets you surprisingly close to the Messages feel.
The main sample uses the iOS 17 onChange overload that does not pass old and new values. If you are targeting iOS 16, use the older onChange(of:) { _ in ... } overload instead.
Start with the scroll view
For a bubble-style chat screen, I usually choose ScrollView plus LazyVStack instead of List. List is great for system rows, swipe actions, edit mode, and table-like screens. Chat bubbles usually need custom spacing, backgrounds, alignment, and fewer row decorations, so ScrollView gives us less to fight.
Here is the full screen:
import SwiftUI
struct ChatMessage: Identifiable, Equatable {
let id = UUID()
var text: String
var isMine: Bool
static let samples = [
ChatMessage(text: "Did you try the new build?", isMine: false),
ChatMessage(text: "Yep. The keyboard behavior is much better now.", isMine: true),
ChatMessage(text: "Nice. Send me the SwiftUI version?", isMine: false)
]
}
struct ChatScreen: View {
@State private var messages = ChatMessage.samples
@State private var draft = ""
@FocusState private var composerFocused: Bool
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 8) {
ForEach(messages) { message in
MessageBubble(message: message)
.id(message.id)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 16)
}
.scrollDismissesKeyboard(.interactively)
.safeAreaInset(edge: .bottom, spacing: 0) {
ChatComposer(
text: $draft,
isFocused: $composerFocused,
onSend: sendMessage
)
.background(.bar)
}
.onAppear {
scrollToLatestMessage(with: proxy, animated: false)
}
.onChange(of: messages.last?.id) {
scrollToLatestMessage(with: proxy)
}
}
.navigationTitle("Messages")
.navigationBarTitleDisplayMode(.inline)
}
private func sendMessage() {
let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
messages.append(ChatMessage(text: text, isMine: true))
draft = ""
composerFocused = true
}
private func scrollToLatestMessage(
with proxy: ScrollViewProxy,
animated: Bool = true
) {
guard let id = messages.last?.id else { return }
Task { @MainActor in
await Task.yield()
if animated {
withAnimation(.easeOut(duration: 0.25)) {
proxy.scrollTo(id, anchor: .bottom)
}
} else {
proxy.scrollTo(id, anchor: .bottom)
}
}
}
}
There are a few important details hiding in there.
Each message has a stable id, and that same id is attached to the rendered bubble with .id(message.id). ScrollViewReader can only scroll to views it can identify, so unstable ids are a recipe for “why did it scroll to the wrong place?” bugs. In a production app, use your server message id when you have one.
The Task.yield() before scrollTo is small but useful. After appending a message, SwiftUI still needs a chance to update the scroll view’s contents. Yielding often gives the new bubble time to appear before we ask the proxy to scroll to it, but treat it as a pragmatic workaround rather than a layout guarantee. On newer targets, the scroll-position APIs can be a more explicit option for screens that need tighter control.
Add the message bubbles
The bubble view can be simple. The main production habit here is to make the alignment readable and give assistive technologies a useful label.
struct MessageBubble: View {
let message: ChatMessage
var body: some View {
HStack {
if message.isMine {
Spacer(minLength: 48)
}
Text(message.text)
.font(.body)
.foregroundStyle(message.isMine ? .white : .primary)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(message.isMine ? Color.accentColor : Color.secondary.opacity(0.14))
}
.accessibilityLabel(
message.isMine
? "You said, \(message.text)"
: "They said, \(message.text)"
)
if !message.isMine {
Spacer(minLength: 48)
}
}
}
}
You can split messages by day, add delivery state, or group consecutive messages from the same sender later. The keyboard-safe layout does not depend on any of that.
Put the composer in the safe area
The composer belongs in safeAreaInset(edge: .bottom), not in a ZStack overlay. The difference matters. An overlay draws on top of the scroll view, so the last message can hide behind the composer unless you manually add matching bottom padding. A safe area inset reserves space for the composer and lets the scroll view lay out above it.
struct ChatComposer: View {
@Binding var text: String
var isFocused: FocusState<Bool>.Binding
let onSend: () -> Void
private var canSend: Bool {
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var body: some View {
HStack(alignment: .bottom, spacing: 8) {
TextField("Message", text: $text, axis: .vertical)
.textFieldStyle(.plain)
.lineLimit(1...5)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background {
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(Color.secondary.opacity(0.12))
}
.overlay {
RoundedRectangle(cornerRadius: 20, style: .continuous)
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
}
.focused(isFocused)
.submitLabel(.send)
.onSubmit {
if canSend {
onSend()
}
}
.accessibilityLabel("Message")
Button(action: onSend) {
Image(systemName: "arrow.up.circle.fill")
.font(.system(size: 32))
}
.disabled(!canSend)
.foregroundStyle(canSend ? Color.accentColor : Color.secondary)
.accessibilityLabel("Send message")
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
}
@FocusState keeps the keyboard behavior intentional. The parent owns composerFocused, passes the projected focus binding into the composer, and sets it back to true after sending. That keeps the keyboard open for the next message, which is usually what people expect in a chat.
The visible send button is not just decoration. It gives touch users an obvious action, gives VoiceOver a clear control, and gives you a reliable send path even when the keyboard return behavior varies by keyboard, locale, or hardware keyboard setup. The .submitLabel(.send) and .onSubmit path is a nice bonus, not the only way to send.
TextField or TextEditor?
For most chat composers, the vertical-axis TextField is the best starting point:
TextField("Message", text: $text, axis: .vertical)
.lineLimit(1...5)
That gives you a field that starts at one line, grows up to five lines, then scrolls internally. It also keeps placeholder behavior and submit handling close to a normal text field.
Use TextEditor when the draft is more like a note: long text, deliberate line breaks, selection-heavy editing, or custom editing controls. The tradeoff is that TextEditor behaves like a long-form editor, so you usually need to provide your own placeholder and rely on the send button instead of return-to-send.
ZStack(alignment: .topLeading) {
if text.isEmpty {
Text("Message")
.foregroundStyle(.secondary)
.padding(.horizontal, 5)
.padding(.vertical, 8)
}
TextEditor(text: $text)
.frame(minHeight: 40, maxHeight: 130)
.scrollContentBackground(.hidden)
.focused(isFocused)
.accessibilityLabel("Message")
}
I would not start with a custom height-measuring text view unless the app truly needs it. The built-in growing TextField handles the boring version well, and boring is wonderful when the keyboard is involved.
Keyboard details to test
Use .scrollDismissesKeyboard(.interactively) on the scroll view when you want the Messages-style drag-to-dismiss feeling. If your app is more form-like, .immediately may be better. If scrolling history should never dismiss the keyboard, use .never.
There are a few common pitfalls worth checking before you ship:
- Avoid
ignoresSafeArea(.keyboard)on the chat screen unless you are deliberately drawing behind the keyboard. It often breaks the exact avoidance behavior you wanted. - Put the composer in
safeAreaInset, not a floating overlay, unless you also handle bottom padding yourself. - Keep a maximum line count on the composer. A pasted paragraph should not eat the whole conversation.
- Scroll after the new message exists in the view hierarchy. Calling
scrollTotoo early is one of the easiest ways to get flaky behavior. - Test with hardware keyboards, iPad split view, larger Dynamic Type, dictation, and rotation. Chat inputs are small, but they live in a very dynamic part of the screen.
Apple docs worth bookmarking:
safeAreaInset(edge:alignment:spacing:content:)FocusStateTextFieldwith anaxisScrollViewReaderscrollDismissesKeyboard(_:)
The pattern is pleasantly small: ScrollView and LazyVStack for the messages, safeAreaInset for the composer, FocusState for keyboard intent, a capped vertical TextField for the draft, and a stable scroll id for the latest message. Build those pieces first, then layer on delivery states, attachments, typing indicators, and all the fun chat-app extras.