How We Built Chat Windows That Don't Make Users Want to Throw Their Laptops
A journey through scroll behavior, IRC formatting codes from the 1990s, and the existential dread of LazyVStack.
Building a chat window seems deceptively simple. It’s just a list of messages, right? You scroll down, new messages appear at the bottom, Bob’s your uncle. If only software development worked like that.
This is the story of how we built the chat interface for cIRC, our Swift/SwiftUI IRC client. It’s a tale of hubris, humility, and learning to respect problems that looked trivial from a distance.
The Humble Requirements
Our chat window needed to do three things:
- Show messages
- Scroll to the bottom when new messages arrive (but only if you’re already at the bottom)
- Parse IRC formatting codes that predate most of our interns
Simple. We’d be done by lunch.
Reader, we were not done by lunch.
Act I: The Scroll Position Debacle
SwiftUI’s ScrollView is a marvel of declarative UI design. It’s also, shall we say, opinionated about how much control it gives you over scroll position.
Our first attempt was charmingly naive:
ScrollView {
LazyVStack {
ForEach(messages) { message in
MessageRow(message: message)
}
}
}
This worked! Messages appeared. We could scroll. Victory was declared, champagne was uncorked, and then someone asked: “Why doesn’t it scroll down when new messages arrive?”
Ah.
The “Am I At The Bottom?” Problem
The fundamental challenge of chat interfaces is this: when a new message arrives, you should scroll to show it—but only if the user was already at the bottom. If they’ve scrolled up to read history, you shouldn’t rudely yank them back down like an overeager Labrador.
SwiftUI, in its infinite wisdom, doesn’t expose scroll position directly. You can’t just ask “hey, are we at the bottom?” You have to infer it, like a detective piecing together clues from a crime scene where the only witness is a GeometryReader that lies.
Our solution involved the new onScrollGeometryChange modifier:
.onScrollGeometryChange(for: Bool.self) { geometry in
let contentHeight = geometry.contentSize.height
let containerHeight = geometry.containerSize.height
let offsetY = geometry.contentOffset.y
let distanceFromBottom = contentHeight - containerHeight - offsetY
return distanceFromBottom <= bottomThreshold
} action: { _, isAtBottom in
guard !isScrolling else { return }
if isAtBottom != isPinnedToBottom {
isPinnedToBottom = isAtBottom
}
}
Elegant? Not particularly. Does it work? After approximately seventeen iterations, yes.
The Recursive Scroll Problem
Here’s where things got properly weird. We needed to scroll to the bottom when content grew, so we added:
.onScrollGeometryChange(for: CGFloat.self) { geometry in
geometry.contentSize.height
} action: { oldHeight, newHeight in
guard isPinnedToBottom else { return }
guard newHeight > oldHeight else { return }
proxy.scrollTo(bottomID, anchor: .bottom)
}
But scrolling changes the geometry. Which triggers the handler. Which checks if we should scroll. Which changes the geometry. You see where this is going.
The solution was a isScrolling flag that we set before programmatic scrolls and clear after a brief delay:
isScrolling = true
proxy.scrollTo(bottomID, anchor: .bottom)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
isScrolling = false
}
Is 0.05 seconds the correct delay? Empirically, yes. Theoretically, we have no idea. It works on our machines, and isn’t that what software engineering is all about?
Act II: The Invisible Bottom Anchor
Messages need to scroll past all content to the true bottom, including any padding. Our first attempts kept leaving a few pixels of the last message cut off, like a bad haircut.
The fix was almost embarrassingly simple once we found it: an invisible anchor element:
LazyVStack {
ForEach(buffer.messages) { message in
MessageRow(message: message)
}
}
.padding()
// The hero we needed
Color.clear
.frame(height: 1)
.id(bottomID)
One pixel of invisible color, carrying the entire scroll experience on its metaphorical shoulders.
Act III: IRC Formatting, or How I Learned to Stop Worrying and Love Control Characters
IRC was born in 1988, which means its text formatting predates not just Unicode, but widespread agreement that control characters should be limited to actually controlling things.
Here’s what we’re dealing with:
\u{02}(Ctrl+B) - Bold\u{03}- Color codes (followed by numbers, naturally)\u{1D}- Italic\u{1F}- Underline\u{0F}- Reset everything\u{16}- Reverse video (swap foreground/background, because why not)
Color codes deserve special mention. The format is \u{03}FG,BG where FG and BG are 1-2 digit numbers mapping to a 16-color palette that was clearly designed by someone who thought “salmon pink” and “brown” were equally important choices.
private let ircColors: [Color] = [
.white, // 0
.black, // 1
Color(red: 0, green: 0, blue: 0.5), // 2 - navy
Color(red: 0, green: 0.5, blue: 0), // 3 - green
.red, // 4
Color(red: 0.5, green: 0, blue: 0), // 5 - brown/maroon
.purple, // 6
.orange, // 7
// ... and so on, each more questionable than the last
]
The Parser That Could
Parsing this required tracking state through the message:
var foreground: Color?
var background: Color?
var isBold = false
var isItalic = false
var isUnderline = false
var isStrikethrough = false
// Every character is a new adventure
switch char {
case "\u{03}":
flushSegment()
// Parse 1-2 digit foreground
// Optionally parse comma + 1-2 digit background
// Question your life choices
The real fun is that \u{03} with no following number resets colors, but \u{03}4 sets foreground to red, and \u{03}4,2 sets foreground to red and background to navy.
We briefly considered just stripping all formatting codes. Then we joined an IRC channel where someone’s username was rendered in rainbow colors, and we knew we had to do this properly.
Act IV: The Buffer and the Deque
Messages need to be stored efficiently. IRC channels can be chatty—thousands of messages aren’t unusual. We capped our buffers at 2,000 messages using swift-collections’ Deque:
public var messages: Deque<MessageState>
public func addMessage(_ message: MessageState, incrementUnread: Bool = true) {
messages.append(message)
if messages.count > Self.maxMessages {
let excess = messages.count - Self.maxMessages
let toRemove = max(excess, Self.trimBatchSize)
messages.removeFirst(min(toRemove, messages.count))
}
}
The trimBatchSize is a minor optimization—removing messages one at a time is wasteful, so we remove 100 at once when we hit the limit. It’s the kind of micro-optimization that probably doesn’t matter but makes us feel clever.
Act V: Tab Completion, or The Feature That Took Three Rewrites
Every IRC client needs tab completion. Type @Al<TAB> and it should complete to @Alice: (with the colon if you’re at the start of the line, because that’s how you address someone on IRC, and yes, this matters to IRC users).
Version 1 didn’t handle repeated tabs for cycling through candidates.
Version 2 handled cycling but broke when you typed anything after completing.
Version 3 worked but had a bug where the completion state wasn’t preserved through SwiftUI’s state updates:
// The hacky but functional solution
let savedCandidates = completionCandidates
let savedIndex = completionIndex
appState.inputText = newText // This triggers onChange, which resets state
// Restore the state that was just reset
completionCandidates = savedCandidates
completionIndex = savedIndex
We’re not proud of it, but we’re not embarrassed either. Sometimes you have to work with the framework you have, not the framework you wish you had.
Lessons Learned
-
Scroll position is harder than it looks. Every chat app has solved this problem, and every chat app has scars to show for it.
-
Legacy protocols carry legacy baggage. IRC’s formatting codes are a window into a time when bandwidth was precious and consistency was for the weak.
-
SwiftUI state management requires respect. Fight the framework and you lose. Work with its grain, accept its quirks, and you might just ship something.
-
The “simple” features take the longest. Nobody spends weeks on the exotic features. It’s the stuff everyone assumes will “just work” that consumes your calendar.
The Result
We now have a chat window that:
- Scrolls smoothly
- Stays at the bottom when it should
- Lets you read history without interruption
- Renders decades-old formatting codes
- Completes nicks with a single tab
- Probably has three bugs we haven’t found yet
It’s not perfect, but it works. And in the grand tradition of IRC itself—a protocol that’s survived 35+ years through sheer stubbornness—sometimes “works” is exactly good enough.
cIRC is a native macOS/iOS IRC client built with Swift 6 and SwiftUI. If you’ve read this far, you’re either very interested in chat interfaces or very good at procrastinating. Either way, we salute you.