Build a Real-Time Chat App: WebSockets, Message Bubbles, and Typing Indicators
Picture this: the entire Pixar production team is scattered across studios, and they need a secure, real-time messaging app to coordinate on the next film — Woody filing storyboard notes to Buzz, Remy and Linguini debating the soup scene in real time, Dory asking what she was just talking about. Your job is to build it.
In this tutorial, you’ll build PixarChat — a fully functional real-time messaging app powered by WebSockets. You’ll
implement a persistent connection manager using
URLSessionWebSocketTask, craft custom
SwiftUI message bubble views, add animated typing indicators, wire up read receipts, and group messages by timestamp. We
won’t cover push notifications or end-to-end encryption — those deserve their own tutorials.
Prerequisites
- Xcode 16+ with an iOS 18 deployment target
- Familiarity with SwiftUI state management
- Familiarity with async sequences
- Understanding of networking layer architecture (TCP connections, HTTP upgrade handshakes)
Note: This tutorial uses a local WebSocket echo/broadcast server for development. Setup instructions are in Getting Started. The same
ChatServicecode works against any production WebSocket backend without modification.
Contents
- Getting Started
- Step 1: Defining the Data Model
- Step 2: Building the WebSocket Connection Manager
- Step 3: Creating the Message Bubble View
- Step 4: Adding Timestamp Grouping
- Step 5: Implementing Typing Indicators
- Step 6: Adding Read Receipts
- Step 7: Wiring Up the Chat View
- Step 8: Handling Connection State and Reconnection
- Where to Go From Here?
Getting Started
Start by creating a fresh Xcode project.
- Open Xcode and select File → New → Project.
- Choose the App template under iOS.
- Set the Product Name to
PixarChat. - Set the Interface to SwiftUI and the Language to Swift.
- Set the Minimum Deployments to iOS 18.0 in the project editor.
Next, you need a local WebSocket server to develop against. Create a file called server.js outside your Xcode project
folder:
// server.js — simple WebSocket broadcast server for local development
// Run with: node server.js
const { WebSocketServer } = require('ws');
const wss = new WebSocketServer({ port: 8080 });
const clients = new Set();
wss.on('connection', (ws) => {
clients.add(ws);
ws.on('message', (data) => {
// Broadcast every incoming frame to all connected clients
for (const client of clients) {
if (client.readyState === 1) client.send(data.toString());
}
});
ws.on('close', () => clients.delete(ws));
});
console.log('WebSocket server running on ws://localhost:8080');
Run it with node server.js (requires Node.js). Every message the app sends is broadcast to all connected clients —
perfect for simulating a shared Pixar production chat room. To see two-way messaging, open a second simulator or a
WebSocket GUI client alongside the running app.
Open your project’s Info.plist (or add the key via the Info tab in project settings) and allow local WebSocket
connections:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
This permits ws://localhost connections. In production you’ll use a wss:// (TLS-secured) endpoint and this exception
is not needed.
Finally, create the following folder structure inside the PixarChat group in Xcode — use File → New → Group to add
folders, then File → New → File → Swift File for each .swift file:
PixarChat/
Models/
ChatMessage.swift
ChatUser.swift
MessageEnvelope.swift
Services/
ChatService.swift
Views/
ChatView.swift
MessageBubbleView.swift
TypingIndicatorView.swift
TimestampDividerView.swift
PixarChatApp.swift
Checkpoint: Build the project (⌘B). You should get a clean build with no errors. The empty folder structure is ready for the code you’re about to add.
Step 1: Defining the Data Model
A solid data model is the foundation of any messaging app. Define your types before touching networking or UI — it keeps the architecture honest.
Open Models/ChatUser.swift and add:
import Foundation
// A member of the Pixar production team.
struct ChatUser: Identifiable, Hashable, Sendable {
let id: UUID
let displayName: String
let avatarInitials: String
// The user running the app on this device.
static let currentUser = ChatUser(
id: UUID(),
displayName: "Remy",
avatarInitials: "R"
)
// A fixed remote participant for demo purposes.
static let remoteUser = ChatUser(
id: UUID(uuidString: "DEADBEEF-0000-0000-0000-000000000001")!,
displayName: "Linguini",
avatarInitials: "L"
)
}
ChatUser conforms to Sendable because it crosses actor isolation boundaries inside ChatService. It’s a value type,
so the compiler confirms this automatically.
Open Models/ChatMessage.swift:
import Foundation
// The canonical in-memory representation of a single chat message.
struct ChatMessage: Identifiable, Sendable {
enum Status: Sendable {
case sending // Optimistically inserted, send in flight
case sent // WebSocket send() returned successfully
case delivered // Remote client received it
case read // Remote client opened the conversation
}
let id: UUID
let sender: ChatUser
let body: String
let sentAt: Date
var status: Status
var isFromCurrentUser: Bool {
sender.id == ChatUser.currentUser.id
}
}
The Status enum drives the read-receipt indicator you’ll build in Step 6. isFromCurrentUser is a computed property —
it derives truth from the sender’s identity, which prevents the two from ever getting out of sync.
Open Models/MessageEnvelope.swift and add the wire format:
import Foundation
// JSON payload sent and received over the WebSocket connection.
struct MessageEnvelope: Codable, Sendable {
enum EventType: String, Codable, Sendable {
case message
case typing
case readReceipt
}
let event: EventType
let messageId: String
let senderId: String
let senderName: String
let body: String
let sentAt: Double // Unix timestamp — avoids locale-sensitive date parsing
static let encoder: JSONEncoder = {
let enc = JSONEncoder()
enc.keyEncodingStrategy = .convertToSnakeCase
return enc
}()
static let decoder: JSONDecoder = {
let dec = JSONDecoder()
dec.keyDecodingStrategy = .convertFromSnakeCase
return dec
}()
}
The static encoder and decoder are shared instances. Creating a fresh JSONEncoder for every message is wasteful —
the setup cost is non-trivial.
Checkpoint: Build the project. All three model files should compile without warnings. If the compiler reports “Type does not conform to ‘Sendable’”, verify every stored property is itself
Sendable(structs with only value-type properties satisfy this automatically).
Step 2: Building the WebSocket Connection Manager
URLSessionWebSocketTask is
Foundation’s WebSocket API, available since iOS 13. It handles the HTTP→WebSocket upgrade handshake, frame
fragmentation, and ping/pong automatically. Your job is to wrap it in a @MainActor-isolated ObservableObject so all
UI state mutations stay on the main thread.
Create Services/ChatService.swift:
import Foundation
@MainActor
final class ChatService: ObservableObject {
// MARK: - Published State
@Published private(set) var messages: [ChatMessage] = []
@Published private(set) var connectionState: ConnectionState = .disconnected
@Published private(set) var remoteUserIsTyping: Bool = false
enum ConnectionState: Equatable {
case disconnected
case connecting
case connected
case failed(String) // String rather than Error — Error isn't Equatable
static func == (lhs: ConnectionState, rhs: ConnectionState) -> Bool {
switch (lhs, rhs) {
case (.disconnected, .disconnected),
(.connecting, .connecting),
(.connected, .connected):
return true
case (.failed(let a), .failed(let b)):
return a == b
default:
return false
}
}
}
// MARK: - Private State
private var webSocketTask: URLSessionWebSocketTask?
private let session: URLSession = URLSession(configuration: .default)
private var receiveTask: Task<Void, Never>?
private var typingTask: Task<Void, Never>?
private var pingTask: Task<Void, Never>?
private let serverURL: URL
init(serverURL: URL = URL(string: "ws://localhost:8080")!) {
self.serverURL = serverURL
}
}
@MainActor isolation means every method and property access on ChatService executes on the main thread. This
eliminates the need for manual DispatchQueue.main.async calls around @Published mutations — the Swift compiler
enforces the isolation at compile time.
Now add the connection and send methods. Append this extension to the same file:
extension ChatService {
// MARK: - Connection Lifecycle
func connect() {
guard connectionState == .disconnected else { return }
connectionState = .connecting
var request = URLRequest(url: serverURL)
request.timeoutInterval = 10
webSocketTask = session.webSocketTask(with: request)
webSocketTask?.resume()
connectionState = .connected
// Kick off the continuous receive loop
receiveTask = Task { [weak self] in
await self?.receiveLoop()
}
// Keepalive ping every 20 s prevents NAT and load-balancer timeouts
pingTask = Task { [weak self] in
while let self, !Task.isCancelled {
try? await Task.sleep(for: .seconds(20))
try? await self.webSocketTask?.sendPing()
}
}
}
func disconnect() {
receiveTask?.cancel()
receiveTask = nil
pingTask?.cancel()
pingTask = nil
typingTask?.cancel()
typingTask = nil
webSocketTask?.cancel(with: .goingAway, reason: nil)
webSocketTask = nil
connectionState = .disconnected
}
// MARK: - Sending
func send(_ text: String) async {
guard connectionState == .connected else { return }
let envelope = MessageEnvelope(
event: .message,
messageId: UUID().uuidString,
senderId: ChatUser.currentUser.id.uuidString,
senderName: ChatUser.currentUser.displayName,
body: text,
sentAt: Date().timeIntervalSince1970
)
// Optimistically append the outgoing message before the send completes
let outgoing = ChatMessage(
id: UUID(uuidString: envelope.messageId)!,
sender: ChatUser.currentUser,
body: text,
sentAt: Date(timeIntervalSince1970: envelope.sentAt),
status: .sending
)
messages.append(outgoing)
do {
let data = try MessageEnvelope.encoder.encode(envelope)
let json = String(data: data, encoding: .utf8)!
try await webSocketTask?.send(.string(json))
updateStatus(for: outgoing.id, to: .sent)
} catch {
// Roll back the optimistic insert on failure
messages.removeAll { $0.id == outgoing.id }
}
}
func sendTypingEvent() {
// Cancel the previous typing task so we debounce keystrokes
typingTask?.cancel()
typingTask = Task { [weak self] in
guard let self, connectionState == .connected else { return }
let envelope = MessageEnvelope(
event: .typing,
messageId: UUID().uuidString,
senderId: ChatUser.currentUser.id.uuidString,
senderName: ChatUser.currentUser.displayName,
body: "",
sentAt: Date().timeIntervalSince1970
)
guard let data = try? MessageEnvelope.encoder.encode(envelope),
let json = String(data: data, encoding: .utf8) else { return }
try? await webSocketTask?.send(.string(json))
}
}
func markAsRead(messageId: UUID) async {
guard connectionState == .connected else { return }
let envelope = MessageEnvelope(
event: .readReceipt,
messageId: messageId.uuidString,
senderId: ChatUser.currentUser.id.uuidString,
senderName: ChatUser.currentUser.displayName,
body: "",
sentAt: Date().timeIntervalSince1970
)
guard let data = try? MessageEnvelope.encoder.encode(envelope),
let json = String(data: data, encoding: .utf8) else { return }
// Read receipts are best-effort — ignore send errors
try? await webSocketTask?.send(.string(json))
updateStatus(for: messageId, to: .read)
}
// MARK: - Private
private func receiveLoop() async {
while !Task.isCancelled {
do {
guard let task = webSocketTask else { break }
let frame = try await task.receive()
handleFrame(frame)
} catch {
if !Task.isCancelled {
connectionState = .failed(error.localizedDescription)
}
break
}
}
}
private func handleFrame(_ frame: URLSessionWebSocketTask.Message) {
let jsonString: String
switch frame {
case .string(let s): jsonString = s
case .data(let d):
jsonString = String(data: d, encoding: .utf8) ?? ""
@unknown default: return
}
guard let data = jsonString.data(using: .utf8),
let envelope = try? MessageEnvelope.decoder.decode(
MessageEnvelope.self, from: data) else { return }
switch envelope.event {
case .message:
// The broadcast server echoes our own messages back — skip them
guard envelope.senderId != ChatUser.currentUser.id.uuidString else {
return
}
let incoming = ChatMessage(
id: UUID(uuidString: envelope.messageId) ?? UUID(),
sender: ChatUser.remoteUser,
body: envelope.body,
sentAt: Date(timeIntervalSince1970: envelope.sentAt),
status: .delivered
)
messages.append(incoming)
remoteUserIsTyping = false
case .typing:
guard envelope.senderId != ChatUser.currentUser.id.uuidString else {
return
}
remoteUserIsTyping = true
// Auto-clear the typing indicator after 3 s of silence
Task {
try? await Task.sleep(for: .seconds(3))
remoteUserIsTyping = false
}
case .readReceipt:
if let msgId = UUID(uuidString: envelope.messageId) {
updateStatus(for: msgId, to: .read)
}
}
}
private func updateStatus(for id: UUID, to status: ChatMessage.Status) {
guard let index = messages.firstIndex(where: { $0.id == id }) else { return }
messages[index].status = status
}
}
Three design decisions worth understanding:
- Optimistic append: The outgoing message lands in
messagesimmediately with.sendingstatus. This feels instant to the user regardless of network latency. If the actual send throws, the message is removed — matching how iMessage handles failed sends. receiveLoop(): Awhile !Task.isCancelledloop suspends atawait task.receive()without blocking any thread. When a WebSocket frame arrives, Swift resumes the loop, processes the frame, then suspends again. Callingcancel()onreceiveTaskcauses the nextreceive()to throwCancellationError, breaking the loop cleanly.- Typing debounce: Cancelling the previous
typingTaskbefore creating a new one means only one typing event fires per burst of keystrokes, not one per character.
Checkpoint: Build the project.
ChatServicecompiles without warnings. The connection isn’t wired to any UI yet — that comes in Step 7.
Step 3: Creating the Message Bubble View
The bubble UI is the most visible part of the app. Build a reusable MessageBubbleView that mirrors the iMessage
aesthetic: outgoing bubbles on the right in the accent color, incoming on the left in a neutral fill, with per-corner
radii creating a subtle tail.
Create Views/MessageBubbleView.swift:
import SwiftUI
struct MessageBubbleView: View {
let message: ChatMessage
private var isOutgoing: Bool { message.isFromCurrentUser }
var body: some View {
HStack(alignment: .bottom, spacing: 6) {
if isOutgoing { Spacer(minLength: 60) }
if !isOutgoing {
avatarView
}
VStack(alignment: isOutgoing ? .trailing : .leading, spacing: 3) {
bubbleBody
metaRow
}
if !isOutgoing { Spacer(minLength: 60) }
}
.padding(.horizontal, 12)
}
// MARK: - Sub-views
private var avatarView: some View {
Circle()
.fill(Color(.systemGray4))
.frame(width: 32, height: 32)
.overlay(
Text(message.sender.avatarInitials)
.font(.caption2.bold())
.foregroundStyle(.secondary)
)
}
private var bubbleBody: some View {
Text(message.body)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
isOutgoing ? Color.accentColor : Color(.systemGray5),
in: BubbleShape(isOutgoing: isOutgoing)
)
.foregroundStyle(isOutgoing ? .white : .primary)
}
private var metaRow: some View {
HStack(spacing: 4) {
Text(message.sentAt, style: .time)
.font(.caption2)
.foregroundStyle(.secondary)
if isOutgoing {
ReadReceiptView(status: message.status)
}
}
}
}
The BubbleShape gives each bubble a rounded rectangle with one corner slightly less rounded — the classic chat tail.
Add it to the same file:
// A rounded rectangle with one tighter corner to suggest a chat tail.
struct BubbleShape: Shape {
let isOutgoing: Bool
func path(in rect: CGRect) -> Path {
let radius: CGFloat = 18
let tailRadius: CGFloat = 6
// The tail corner is top-right for outgoing, top-left for incoming
return Path(roundedRect: rect, cornerRadii: RectangleCornerRadii(
topLeading: isOutgoing ? radius : tailRadius,
bottomLeading: radius,
bottomTrailing: radius,
topTrailing: isOutgoing ? tailRadius : radius
))
}
}
Apple Docs:
RectangleCornerRadii— SwiftUI, iOS 16+
The per-corner radius API replaces the cumbersome manual Bezier path workaround that was common in UIKit chat UIs.
Now add ReadReceiptView to the same file:
struct ReadReceiptView: View {
let status: ChatMessage.Status
var body: some View {
Group {
switch status {
case .sending:
Image(systemName: "clock")
case .sent:
Image(systemName: "checkmark")
case .delivered:
Image(systemName: "checkmark.circle")
case .read:
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.blue)
}
}
.font(.caption2)
.foregroundStyle(.secondary)
.transition(.opacity)
.animation(.easeInOut(duration: 0.25), value: status)
}
}
Each status maps to a recognizable SF Symbol. The .animation(_:value:) modifier cross-fades between icons as the
status upgrades from .sending → .sent → .delivered → .read.
Checkpoint: Add a SwiftUI preview to
MessageBubbleView.swiftto verify the layout:
#Preview {
VStack(spacing: 8) {
MessageBubbleView(message: ChatMessage(
id: UUID(),
sender: .currentUser,
body: "We need to talk about the lamp scene.",
sentAt: Date(),
status: .read
))
MessageBubbleView(message: ChatMessage(
id: UUID(),
sender: .remoteUser,
body: "Too much lamp? I think it needs more lamp.",
sentAt: Date(),
status: .delivered
))
}
.padding(.vertical)
}
You should see the outgoing bubble on the right in the accent color with a blue “read” checkmark, and the incoming bubble on the left in gray with Linguini’s avatar.
Step 4: Adding Timestamp Grouping
Real chat apps don’t show a timestamp beside every message — that creates visual noise. Instead they insert a single centered divider between bursts of messages more than a few minutes apart.
Create Views/TimestampDividerView.swift:
import SwiftUI
struct TimestampDividerView: View {
let date: Date
var body: some View {
HStack {
line
Text(formatted(date))
.font(.caption2)
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.fixedSize() // Prevents the label truncating in narrow layouts
line
}
.padding(.vertical, 4)
.padding(.horizontal, 16)
}
private var line: some View {
Rectangle()
.fill(Color(.separator))
.frame(height: 0.5)
}
// Returns a human-readable string appropriate to how old the date is.
private func formatted(_ date: Date) -> String {
let calendar = Calendar.current
if calendar.isDateInToday(date) {
return date.formatted(date: .omitted, time: .shortened)
} else if calendar.isDateInYesterday(date) {
return "Yesterday " + date.formatted(date: .omitted, time: .shortened)
} else {
return date.formatted(date: .abbreviated, time: .shortened)
}
}
}
Now add a free function to Models/ChatMessage.swift (outside the struct) that determines when to insert a divider:
/// Returns `true` if a timestamp divider should appear before `message`.
/// Dividers separate bursts that are more than 5 minutes apart.
func shouldShowTimestamp(before message: ChatMessage, after prior: ChatMessage?) -> Bool {
guard let prior else { return true }
return message.sentAt.timeIntervalSince(prior.sentAt) > 5 * 60
}
Five minutes is the threshold iMessage uses. The function takes an optional prior message rather than an index because
the call site in the view iterates with enumerated(), making it easy to pass the previous element without reaching
back into the array.
Step 5: Implementing Typing Indicators
The three-dot typing indicator signals that someone is composing a message. It’s a small touch that dramatically increases the sense of presence in any real-time app.
Create Views/TypingIndicatorView.swift:
import SwiftUI
struct TypingIndicatorView: View {
@State private var animatingDotIndex: Int = 0
private let dotSize: CGFloat = 8
private let dotSpacing: CGFloat = 5
var body: some View {
HStack(alignment: .bottom, spacing: 6) {
// Avatar matches the remote user's bubble layout
Circle()
.fill(Color(.systemGray4))
.frame(width: 32, height: 32)
.overlay(
Text(ChatUser.remoteUser.avatarInitials)
.font(.caption2.bold())
.foregroundStyle(.secondary)
)
HStack(spacing: dotSpacing) {
ForEach(0..<3, id: \.self) { index in
Circle()
.fill(Color(.systemGray3))
.frame(width: dotSize, height: dotSize)
.scaleEffect(animatingDotIndex == index ? 1.4 : 1.0)
.animation(
.easeInOut(duration: 0.4)
.repeatForever(autoreverses: true),
value: animatingDotIndex
)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(
Color(.systemGray5),
in: RoundedRectangle(cornerRadius: 18, style: .continuous)
)
Spacer(minLength: 60)
}
.padding(.horizontal, 12)
.onAppear { startAnimation() }
.transition(.asymmetric(
insertion: .move(edge: .bottom).combined(with: .opacity),
removal: .opacity
))
}
private func startAnimation() {
Task {
while !Task.isCancelled {
for i in 0..<3 {
withAnimation { animatingDotIndex = i }
try? await Task.sleep(for: .milliseconds(300))
}
}
}
}
}
The asymmetric transition gives the indicator a slide-up-and-fade-in entrance and an instant fade-out on removal. This matches the behavior of Messages on iOS — the bubble doesn’t awkwardly collapse when the first incoming message arrives.
Tip: If the scale animation looks jerky in the simulator, run on a physical device. The
repeatForever(autoreverses: true)modifier is GPU-composited and runs at full frame rate on hardware.
Step 6: Adding Read Receipts
The ReadReceiptView from Step 3 already renders the correct SF Symbol for each status. The remaining work is sending a
readReceipt event over the WebSocket so the remote participant’s outgoing messages upgrade to .read.
The markAsRead(messageId:) method on ChatService (written in Step 2) handles this. You’ll call it from the chat view
in Step 7 using onAppear when the
message list becomes visible.
A production implementation would be more selective — only send a receipt for messages that were genuinely new when the
user opened the conversation, and only while the app is in the foreground. For this tutorial, calling markAsRead on
the last incoming message when ChatView appears is a clean approximation.
Note: The broadcast server echoes the
readReceiptevent back to all clients, including the sender.handleFrameinChatServicematches themessageIdin the receipt against the outgoing messages and upgrades their status. In a production backend you’d route receipts only to the original sender.
Step 7: Wiring Up the Chat View
With all the components built, assemble them into the main ChatView.
Create Views/ChatView.swift:
import SwiftUI
struct ChatView: View {
@StateObject private var service = ChatService()
@State private var inputText: String = ""
@FocusState private var isInputFocused: Bool
var body: some View {
NavigationStack {
VStack(spacing: 0) {
connectionBanner
messageList
inputBar
}
.navigationTitle("Pixar Production Chat")
.navigationBarTitleDisplayMode(.inline)
.toolbar { statusDot }
}
.task {
// .task ties the connection to the view's lifetime automatically
service.connect()
}
.onDisappear {
service.disconnect()
}
.onChange(of: service.connectionState) {
if case .failed = service.connectionState {
service.reconnectIfNeeded()
}
}
}
// MARK: - Connection Banner
@ViewBuilder
private var connectionBanner: some View {
switch service.connectionState {
case .connecting:
banner("Connecting to the studio...", color: .orange)
case .failed(let msg):
banner("Connection lost: \(msg)", color: .red)
default:
EmptyView()
}
}
private func banner(_ text: String, color: Color) -> some View {
Text(text)
.font(.caption)
.foregroundStyle(.white)
.padding(.vertical, 6)
.frame(maxWidth: .infinity)
.background(color)
.transition(.move(edge: .top).combined(with: .opacity))
}
// MARK: - Message List
private var messageList: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 2) {
ForEach(
Array(service.messages.enumerated()),
id: \.element.id
) { index, message in
let prior = index > 0 ? service.messages[index - 1] : nil
if shouldShowTimestamp(before: message, after: prior) {
TimestampDividerView(date: message.sentAt)
.padding(.top, 8)
}
MessageBubbleView(message: message)
.id(message.id)
}
if service.remoteUserIsTyping {
TypingIndicatorView()
.id("typing")
.padding(.top, 4)
}
}
.padding(.vertical, 8)
.animation(.default, value: service.messages.count)
.animation(.default, value: service.remoteUserIsTyping)
}
.onChange(of: service.messages.count) {
scrollToBottom(proxy: proxy)
}
.onChange(of: service.remoteUserIsTyping) {
if service.remoteUserIsTyping {
withAnimation {
proxy.scrollTo("typing", anchor: .bottom)
}
}
}
.onAppear {
// Mark the last incoming message as read when the view appears
Task {
if let last = service.messages.last(
where: { !$0.isFromCurrentUser }) {
await service.markAsRead(messageId: last.id)
}
}
}
}
}
// MARK: - Input Bar
private var inputBar: some View {
HStack(spacing: 10) {
// axis: .vertical enables the multi-line grow behavior (iOS 16+)
TextField("Message the team...", text: $inputText, axis: .vertical)
.textFieldStyle(.plain)
.lineLimit(1...5)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
Color(.systemGray6),
in: RoundedRectangle(cornerRadius: 20, style: .continuous)
)
.focused($isInputFocused)
.onChange(of: inputText) {
if !inputText.isEmpty {
service.sendTypingEvent()
}
}
sendButton
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.bar)
}
private var sendButton: some View {
let isEmpty = inputText.trimmingCharacters(in: .whitespaces).isEmpty
return Button {
sendMessage()
} label: {
Image(systemName: "arrow.up.circle.fill")
.font(.title2)
.foregroundStyle(isEmpty ? Color(.systemGray3) : Color.accentColor)
}
.disabled(isEmpty)
}
// MARK: - Toolbar
@ToolbarContentBuilder
private var statusDot: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
Circle()
.fill(
service.connectionState == .connected
? Color.green : Color.red
)
.frame(width: 10, height: 10)
}
}
// MARK: - Actions
private func sendMessage() {
let trimmed = inputText.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return }
inputText = ""
Task { await service.send(trimmed) }
}
private func scrollToBottom(proxy: ScrollViewProxy) {
guard let last = service.messages.last else { return }
withAnimation(.easeOut(duration: 0.2)) {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
}
Key architectural decisions:
.taskmodifier: Attachesconnect()to the view’s SwiftUI task lifetime. When the view disappears, the task is cancelled automatically. TheonDisappearis still useful for callingdisconnect()explicitly to cancel in-flight receive tasks.LazyVStackinsideScrollView: Renders only visible rows, equivalent toList’s virtualization without the visual constraints aListimposes on bubble layouts.axis: .verticalTextField: Multi-line expansion from iOS 16. ThelineLimit(1...5)clamps the height to five lines maximum before the field scrolls internally.
Now update PixarChatApp.swift:
import SwiftUI
@main
struct PixarChatApp: App {
var body: some Scene {
WindowGroup {
ChatView()
}
}
}
Checkpoint: Make sure
node server.jsis running, then build and run (⌘R). You should see the “Pixar Production Chat” navigation title, a green dot in the top-right, and an empty message list with the input bar at the bottom. Type a message and tap Send — the blue bubble should appear on the right with a clock icon, then a single checkmark as the send completes. Open a second simulator or WebSocket client connected tows://localhost:8080to see Linguini’s incoming message arrive and the typing indicator animate.
Step 8: Handling Connection State and Reconnection
WebSocket connections drop — Wi-Fi switches, servers restart during deployments, idle timeouts evict the connection. A production chat app must reconnect automatically.
Open Services/ChatService.swift and add the reconnection method inside the existing extension:
func reconnectIfNeeded() {
guard case .failed = connectionState else { return }
Task { [weak self] in
guard let self else { return }
var delay: Double = 1.0
let maxDelay: Double = 30.0
while !Task.isCancelled {
// Wait before attempting — first retry after 1 s
try? await Task.sleep(for: .seconds(delay))
// Tear down the old task before creating a new one
disconnect()
connect()
// Exit the retry loop if we're now connected
if case .connected = connectionState { break }
// Exponential backoff capped at 30 s
delay = min(delay * 2, maxDelay)
}
}
}
Exponential backoff prevents a thundering herd problem: if a server restarts and many clients reconnect simultaneously at 1-second intervals, they hammer the server. Staggering retries with growing delays gives the server time to recover.
Warning: In Swift 6,
Task { [weak self] in ... }inside a@MainActor-isolated method crosses actor isolation. The[weak self]capture prevents a retain cycle, andguard let selfshort-circuits cleanly if the service is deallocated during a sleep interval. Always use[weak self]in long-livedTaskclosures insideObservableObjectclasses.
The .onChange handler added in ChatView (Step 7) already calls reconnectIfNeeded() when connectionState
transitions to .failed.
The
URLSessionWebSocketTask.sendPing()
call in connect() (Step 2) prevents the connection from silently timing out between messages. Most NAT gateways and
cloud load balancers drop idle TCP connections after 60–120 seconds. A ping every 20 seconds keeps the mapping alive.
Checkpoint: Build and run. With the app connected, stop the Node server (Ctrl-C). The red “Connection lost” banner should slide in. Restart the server — within a few seconds, the banner should animate away and the green dot should return. Send a message immediately after reconnection to confirm the new WebSocket task is fully operational.
Where to Go From Here?
Congratulations! You’ve built PixarChat — a fully functional real-time messaging app featuring WebSocket-powered delivery, custom bubble UI with per-corner radius shapes, animated typing indicators, read receipt upgrades, timestamp grouping, and automatic exponential-backoff reconnection.
Here’s what you learned:
- How to manage a
URLSessionWebSocketTasklifecycle inside a@MainActor-isolatedObservableObject, eliminating manual dispatch calls. - How to implement a continuous receive loop with Swift structured concurrency — suspending without blocking a thread, cancelling cleanly with task cancellation.
- How to use optimistic UI updates to make the app feel instant regardless of round-trip latency.
- How to build a custom
Shapeusing per-cornerRectangleCornerRadiifor the bubble tail. - How to group messages with a timestamp divider using a time-interval threshold function.
- How to implement exponential-backoff reconnection tied to SwiftUI’s
onChangemodifier.
Ideas for extending this project:
- Image messages: Add a
PhotosPickerbutton to the input bar. For small images, encode as base64 and embed in thebodyfield. For larger images, upload to a CDN and send the URL. - Reactions: Long-press a bubble to show a
contextMenuwith emoji reaction options. Store them as[String: Int](emoji → count) onChatMessageand render them as a small overlay. - Multiple participants: Replace
ChatUser.remoteUserwith a dictionary keyed by sender ID. The server would include a sender ID in each frame, andhandleFramewould resolve the correctChatUserfrom the registry. - Actor isolation: Refactor
ChatServicefrom@MainActorto a dedicatedactorthat sends UI updates back to the main actor explicitly. This is the right move for a service with heavy background processing. See Actors in Swift for a deep dive on the trade-offs. - Custom view modifiers: Extract the bubble background + shape combination into a reusable
ViewModifierso the same style can be applied to image bubbles and other message types without duplication. SwiftUI Custom View Modifiers covers the full pattern.