iMessage in SwiftUI

iMessage is the GOAT; we all know this. A small, non-exhaustive list of iMessage features you take for granted: bubbles only have a tail if they’re the last one. Timestamps seemingly only when you need them. “Delivered” and “Read” fading in nicely at the bottom. The three (very subtly breathing) dots, the keyboard drags interactively when you swipe, and the whole thing moves down to the bottom when you send a message. It all seems sort of “obvious.” But these things and many, many more taken together are what make iMessage feel so good.

I’ve always wanted to just drag and drop iMessage into my own apps. A component just like a sheet or a navigation stack. But sadly that doesn’t exist. So I built UnionChat.

UnionChat: a one-on-one conversation with tailed bubbles, left and right alignment, grouped runs of messages, a title bar, and an input fieldThis is UnionChat. And you can add it to your app now.

The package is at github.com/unionst/union-chat.

Getting started

The easiest chat is a Chat with some Messages in it.

import UnionChat
 
Chat {
    Message("Hey!", role: .user(id: "alex"), timestamp: .now)
    Message("Hi 👋", role: .me, timestamp: .now)
}

.me is the right side, tinted; .user is on the left; and .system is a message in the middle. That code snippet is a polished conversation. Nice.

Adding data

Real chats of course have data that’s constantly changing. So Chat also can take in a collection and will automatically turn every element into a bubble. The only constraint is that your model needs to conform to 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 doesn’t own your data. It just reacts as it changes. So when someone hits send, for example, onChatSend gives you the text they sent along with any attachment, and you can take an action on it. And to show “Delivered” and “Read,” you add an optional seenAt property to your model. The bubbles will automatically update from there.

Everything else is a modifier:

And of course, that last modifier is how you make the bubbles fit into your app. They take in any ShapeStyle:

Chat(messages) { message in
    Message(message.text, role: message.role, timestamp: message.timestamp)
}
.chatBubbleStyle(Color.pink.gradient)

The same one-on-one conversation as the hero, but the sent bubbles are the site’s hot-pink instead of iMessage bluePink bubbles. Kinda nice.

If you add 3 or more participants to the chat, the UI will automatically start adding avatars and names. Just like iMessage:

A three-person group chat with circular gradient avatars beside each incoming bubble, the sender’s name above it, and a centered system message at the topWith 3 or more participants, the package automatically shows names and avatars.

The Chat { } builder

I REALLY wanted to get the API nice. I wanted it to be a true result builder just like Map { } or Chart { }. So behind the scenes, that’s what we’re using. The same way SwiftUI uses @resultBuilder. This makes it so so nice to write the code (not that you will, but your agent will appreciate it):

Chat {
    if conversation.isEmpty {
        Message("Say hello 👋", role: .system, timestamp: .now)
    }
 
    ForEach(conversation) { message in
        Message(message.text, role: message.role, timestamp: message.timestamp)
    }
}

Behind the scenes, the builder flattens your chat into an array of messages. It handles all the ifs and ForEaches as you’d expect. Trying to get the package to use the SwiftUI ForEach struct (which conforms to View, but also MapContent and many more!) was extremely difficult. I had to get a little cursed.

In order to keep SwiftUI’s real ForEach inside the chat builder, UnionChat has to reinterpret it with an unsafeBitCast. It’s beautiful.

The eighty percent that isn’t SwiftUI

A flat array of messages is the easy part. Making it feel like Messages is the rest of the work. That’s why, under the hood, UnionChat isn’t SwiftUI at all. It’s a UICollectionView wrapped to look 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.

No more jumping when messages come in

A huge problem with chat interfaces is that they’re backwards scroll views. Content comes in at the bottom, you start at the bottom, and you have to gracefully handle this. What happens when you’re scrolled up? Or at the bottom?

UnionChat handles both like iMessage. At the bottom, when new messages come in, they slide into view. But when you’ve scrolled up a bit, it doesn’t move. They just get added to the bottom of the scroll view invisibly.

Drag the keyboard down like normal

In iMessage, you can just drag the keyboard down with your finger. The scroll view slides with it, and it all feels natural. That’s not natural to build. To make it all feel nice, we have to correct literally frame-by-frame. Every single time the keyboard changes, UnionChat remeasures how far it moved and pushes the scroll down by exactly that much:

let delta = previousKeyboardMinY - newMinY
if delta > 0 {
    collectionView.contentOffset.y += delta
}

Spring physics

If you scroll through iMessage quickly, you’ll see the bubbles spring around a little. They lag and then settle in different places. UnionChat uses a UIDynamicAnimator to get the same effect. Every cell sort of sits on a “spring,” and they all slide around as you move. Here’s the formula we’re using:

let scrollResistance = yDistanceFromTouch / 1500.0
center.y += max(delta, delta * scrollResistance)
Scrolling around quickly, you can see the bubble spring physics. Try it at 0.5x.

And so many little things

Much of the work on this package was the big stuff. But maybe even more was on the little things.

For example, a tail is only drawn when the next bubble is a different sender or if the previous message was sent more than 60 seconds prior. Just like iMessage (of course).

The bubble itself is a hand-traced Shape whose geometry scales with the corner radius. Images come in with a BlurHash placeholder. And of course, messages come in with a custom short, soft haptic that feels exactly like the haptic in iMessage. Even that wasn’t shipped by Apple.

The same conversation with a small bubble at the bottom-left containing three grey dots — the typing indicator — shown mid-animationThe typing indicator: a bubble of three dots that breathe in a wave.

All these little things, and many others, add up to the feeling that the chat UI is just right. It’s hard to explain. When you use it, you don’t notice anything. But when anything is off, you do. So feel free to use UnionChat in your project. And let me know if you do!

UnionChat is on GitHub. Get the package.

Note

UnionChat needs iOS 18 or later, builds with Swift 6, and installs through the Swift Package Manager. It ships as a commercial SDK — see the repo for license details.