iMessage clone in SwiftUI
Every chat app is quietly measured against iMessage. Bubbles with tails that know when they’re the last in a run. Timestamps that group themselves. “Delivered,” then “Read,” fading in under the right message. Three breathing dots while the other person types, a keyboard you can drag down with your thumb, and a list that snaps to the newest message the instant you send. None of it is flashy. All of it is the line between a chat screen that feels finished and one that feels homemade.
I wanted that screen for my own apps, and I didn’t want to rebuild it every time. So I built UnionChat: the whole thing as a single SwiftUI view.
Every pixel here is UnionChat, running in the iOS Simulator — no Messages app in sight.
The package lives at github.com/unionst/union-chat.
Start with two lines
The smallest possible chat is a Chat with a couple of Messages inside it:
import UnionChat
Chat {
Message("Hey!", role: .user(id: "alex"), timestamp: .now)
Message("Hi 👋", role: .me, timestamp: .now)
}role is the only thing a bubble really needs: .me lands on the right, tinted; .user(id:displayName:) lands on the left; .system sits centered. That’s a finished-looking conversation, no state, no plumbing.
Wire it to your data
Real chats come from an array that changes, so Chat also takes your own collection and lets you turn each element into a bubble. Your model is yours — it only has to be Identifiable:
struct ConversationMessage: Identifiable {
let id = UUID()
let text: String
let role: ChatRole
let timestamp: Date
var seenAt: Date?
}
struct ConversationView: View {
@State private var messages: [ConversationMessage] = []
var body: some View {
Chat(messages) { message in
Message(message.text, role: message.role, timestamp: message.timestamp)
}
.chatInputPlaceholder("Message Alex…")
.onChatSend { text, media in
guard let text, !text.isEmpty else { return }
messages.append(ConversationMessage(text: text, role: .me, timestamp: .now))
}
}
}The view never owns your data; it reads it. onChatSend hands you the text and any attachment so you decide what a sent message means, and the optional seenAt on your own type is what flips “Delivered” to “Read.” The rest of the API is a handful of modifiers in the same spirit — .chatHeader { } for the title bar, .chatInputCapabilities([.photoLibrary, .files]) for the attachment button, .chatAutoscrollBehavior(.whenAtBottom), and .chatBubbleStyle(_:) to recolor the bubbles.
That last one is why this looks like my site and not a screenshot of Messages — one modifier and the bubbles take any ShapeStyle you hand them:
Chat(messages) { message in
Message(message.text, role: message.role, timestamp: message.timestamp)
}
.chatBubbleStyle(Color.pink.gradient)
Same package, one line of difference — recolored to the site’s accent.
Add a third participant and it sorts the rest out for you — avatars and names appear on their own, exactly the way group threads work in Messages:
Three or more participants, and avatars and names switch on automatically.
The Chat { } builder
Here’s the part I’m proud of. That Chat { … } block isn’t special syntax — it’s a result builder, the same Swift feature behind SwiftUI’s @ViewBuilder. Which means everything you reflexively expect from SwiftUI just works inside a chat:
Chat {
if conversation.isEmpty {
Message("Say hello 👋", role: .system, timestamp: .now)
}
ForEach(conversation) { message in
Message(message.text, role: message.role, timestamp: message.timestamp)
}
}Two things make that feel as clean as it does. The first is that the builder is written with parameter packs — buildBlock(_ content: repeat each Content) — so any number of differently-typed messages fold into one value with zero overloads. SwiftUI’s own TupleView famously ships ten hand-written buildBlocks for one-through-ten children; this is the version that feature was waiting for.
To let SwiftUI’s real ForEach live inside the chat builder, UnionChat reinterprets it: a view-backed ForEach and a content-backed one have the same memory layout, so an unsafeBitCast quietly turns one into the other. It shouldn’t be allowed, and it’s wonderful.
The second is that a Message doesn’t draw itself the way a View does. It accepts a visitor — a little walker that traverses the whole declarative tree, expands every if and ForEach, and flattens it into a plain array of messages. That flat array is what gets handed to the engine. The builder, in other words, is a pretty way to describe a conversation; the visitor quietly compiles your description down into data.
Which is the easy, lovely part. The hard part is what the engine does with that data next.
The eighty percent that isn’t SwiftUI
A flat array of messages is the easy part. Making it behave like Messages is where the work hides — and it’s the reason that, under the SwiftUI surface, UnionChat isn’t SwiftUI at all. It’s a UICollectionView driven by a UIKit view controller, wrapped back into a UIViewControllerRepresentable so it looks like any other view at your call site.
The cells are SwiftUI, hosted inside the collection view. So you write bubbles in SwiftUI and get UIKit’s scrolling underneath.
I didn’t reach for UIKit out of nostalgia. A SwiftUI List or ScrollView simply can’t do the four things iMessage does without thinking, and every one of them is a separate hard problem.
The list that doesn’t jump
Load older history at the top of a normal scroll view and the content lurches — you prepend a screen of messages and the thing you were reading flies off-screen. iMessage never does this.
UnionChat keeps a sliding window of messages in the collection view rather than the entire history, and watches how fast new ones arrive. A controller samples the message rate every half-second and, on a two-second cadence, decides whether you’re in a calm conversation or a burst. Past three messages a second it flips into a mode that suppresses the reflexive scroll-to-bottom and releases the backlog in gentle batches, so a flood of messages files in instead of strobing. The list stays put under your thumb because, structurally, nothing above the fold is ever inserted — the window slides, the offsets don’t.
The keyboard you can drag
In Messages you can drag the keyboard down with the list and it tracks your finger in lockstep. That’s not a SwiftUI affordance — it’s the collection view’s keyboardDismissMode = .interactive plus a frame-by-frame correction. Each time the keyboard’s frame changes, UnionChat measures how far it moved and pushes the scroll offset by exactly that much:
let delta = previousKeyboardMinY - newMinY
if delta > 0 {
collectionView.contentOffset.y += delta
}Two numbers, one per frame, and the messages stay welded to the top of the keyboard as it slides — whether the system is animating it or your thumb is.
Bubbles with a little physics
Watch iMessage scroll fast and the bubbles have weight; they lag and settle instead of moving as one rigid sheet. UnionChat gets that from a custom flow layout backed by a UIDynamicAnimator. Every visible cell is hung from a spring — a UIAttachmentBehavior with damping of 3 and frequency of 15 — and as you scroll, each cell resists by how far it sits from your touch:
let scrollResistance = yDistanceFromTouch / 1500.0
center.y += max(delta, delta * scrollResistance)Cells near your finger keep up; distant ones drag and catch up a beat later. It’s a few lines of physics standing in for the single most “feel”-defining detail in the whole interface.

The small print
The big mechanisms are invisible when they work. So are the small ones, which is the point.
A tail is drawn only when the next bubble is a different sender, or more than sixty seconds later. Same rule iMessage uses to decide where a run of messages ends.
The bubble tail is a hand-built Shape whose geometry scales with the corner radius, mirrored for left versus right, and drawn only on the last bubble of a run. The typing indicator isn’t three blinking dots — it’s a wave, each dot’s opacity a function of its distance from a phase that sweeps across them, so they breathe in sequence. Images arrive behind a BlurHash placeholder — a couple dozen bytes that decode into a blurred preview — so a photo has shape and color the moment it lands and sharpens as Kingfisher streams it in. And a received message lands with a short, soft haptic, throttled so a burst doesn’t buzz your hand off.
The typing indicator: a bubble of three dots that breathe in a wave.
None of these is the feature. Together they’re the difference between a chat UI and the chat UI — which is the whole reason to not build it yourself.
UnionChat is on GitHub. Get the package.