Swift realtime chat ui patterns ios26
Skill esaldgut/ai-native-engineering-workspace/global-skills/apple/swift-realtime-chat-ui-patterns-ios26
Build a real-time chat UI in SwiftUI from primitives (there is no Apple "ChatKit") — date-grouped message bubbles with LazyVStack(pinnedViews:[.sectionHeaders]), scroll-to-bottom-on-new-message via ScrollPosition(.bottom), swipe-to-reply with .swipeActions on List rows, a typing indicator scoped to its own @Observable state so it doesn't redraw the thread, and a URLSessionWebSocketTask stream bridged to @Observable conversation state with optimistic local-first updates. Documents which pieces are iOS 15/17/18 and that presence/typing require a server push (no client API). Use when implementing a messaging thread, status indicators, typing, or swipe-to-reply the native way.From its SKILL.md
npx -y skills add esaldgut/ai-native-engineering-workspace --skill swift-realtime-chat-ui-patterns-ios26Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
9.8 KB, ~2.0k tokens by cl100k_base, as published. Nobody here has run it
Real-time chat UI patterns (SwiftUI, iOS 26)
There is no Apple-blessed "ChatKit" — a chat thread is composition over SwiftUI primitives plus
Swift Concurrency for the live stream. The load-bearing pieces are
LazyVStack with pinned section
headers for date grouping,
ScrollPosition for
scroll-to-bottom, swipeActions
for swipe-to-reply, and a URLSessionWebSocketTask
stream surfaced through @Observable state. This skill is framework-agnostic about the
transport — bring your own socket; iOS 26 only adds Liquid Glass to the bubbles for free.
When to invoke
- You're implementing a messaging thread: bubbles, date separators, status (sent/delivered/read), typing indicator, swipe-to-reply, or scroll-to-bottom on a new message.
- You're bridging a WebSocket message stream into SwiftUI and want the invalidation to stay scoped (one new message shouldn't redraw the whole thread).
Announce on invoke: "Using swift-realtime-chat-ui-patterns-ios26 to build the thread from SwiftUI primitives + a WebSocket-backed @Observable."
Do not invent a presence/typing API — Apple has none. Typing/online state is a server push concern; this skill renders it but does not synthesize it.
The canonical APIs (verified)
| API | Signature / form (verified) | iOS gate | Use |
|---|---|---|---|
LazyVStack(pinnedViews:) | init(alignment:spacing:pinnedViews:content:); pinnedViews: [.sectionHeaders] | 14.0+ | Date-section bubbles with sticky headers |
ScrollPosition | ScrollPosition(idType:); .scrollTo(edge: .bottom) | 18.0+ | Programmatic scroll-to-bottom |
.scrollPosition(_:anchor:) | func scrollPosition(_ position: Binding<ScrollPosition>, anchor: UnitPoint? = nil) -> some View | 18.0+ | Keep newest message pinned |
.scrollTargetLayout() | marks the layout whose items the position tracks | 17.0+ | Required for id-based scroll position |
.swipeActions(edge:allowsFullSwipe:content:) | func swipeActions<T>(edge: HorizontalEdge = .trailing, allowsFullSwipe: Bool = true, @ViewBuilder content:) -> some View | 15.0+ | Swipe-to-reply / delete (List rows) |
URLSessionWebSocketTask | send(_:completionHandler:), receive(completionHandler:); ws:/wss: | 13.0+ | The live message transport |
@Observable | macro | 17.0+ | Scoped conversation / typing state |
The rules (load-bearing)
1. Group by day with pinned section headers — pinnedViews only works in a lazy stack
pinnedViews: [.sectionHeaders] pins inside LazyVStack/LazyHStack only — a plain VStack
ignores it. Wrap the thread in ScrollView { LazyVStack(pinnedViews: [.sectionHeaders]) { … } } with
a Section(header:) per day.
2. Scroll-to-bottom: pick ONE mechanism
iOS 18's ScrollPosition (.scrollTo(edge: .bottom) or .scrollPosition($pos, anchor: .bottom))
and the older ScrollViewReader.scrollTo(id, anchor:) compete inside the same ScrollView —
using both fights for control. Prefer ScrollPosition on iOS 18+; fall back to ScrollViewReader
only on iOS 17. Drive it from .onChange(of: messages.last?.id).
3. Swipe-to-reply uses .swipeActions, which requires a List row
.swipeActions is a List-row affordance. If your thread is a LazyVStack (common, for custom
bubble layout), .swipeActions won't apply — implement reply-swipe with a DragGesture + offset on
the bubble instead. Don't expect .swipeActions to work on a non-List row.
4. Scope typing-indicator state so it doesn't redraw the thread
A typing indicator that lives on the same @Observable as messages will invalidate every bubble
on each keystroke event. Give it its own @Observable TypingState; Observation's per-property
tracking then redraws only the indicator view.
@Observable final class TypingState { var peersTyping: Set<UserID> = [] } // isolated invalidation
5. Optimistic local-first, then reconcile — it's a pattern, not an API
Append the outgoing message to @Observable state immediately with a .sending status, send over
the socket, then flip to .sent/.failed on ack. Model status as an enum on the message; never
block the UI on the round-trip.
6. The socket stream is Sendable work — bridge it on the main actor
Read frames off the URLSessionWebSocketTask in a Task, decode to Sendable message values, and
hand them to the @MainActor @Observable store. Don't mutate UI state off-actor.
Canonical example
struct Conversation: View {
@State private var store: ChatStore // @MainActor @Observable
@State private var position = ScrollPosition(idType: Message.ID.self)
var body: some View {
ScrollView {
LazyVStack(pinnedViews: [.sectionHeaders]) {
ForEach(store.days, id: \.date) { day in
Section {
ForEach(day.messages) { msg in
Bubble(message: msg) // shows .sending/.sent/.read status
.id(msg.id)
}
} header: {
DateSeparator(date: day.date).background(.bar)
}
}
}
.scrollTargetLayout()
}
.scrollPosition($position, anchor: .bottom)
.onChange(of: store.messages.last?.id) { _, _ in
withAnimation { position.scrollTo(edge: .bottom) } // newest message into view
}
.safeAreaInset(edge: .bottom) {
Composer { text in store.sendOptimistically(text) } // local-first append
}
.task { await store.connect() } // URLSessionWebSocketTask stream
}
}
Decision aid: when NOT to / trade-offs
ListvsLazyVStack:Listgives you.swipeActionsand recycling for free but constrains bubble styling and insets. Custom bubbles →LazyVStack+ manual swipe gesture. Pick based on how bespoke the bubble design is.- Don't ship a bundled socket client in the UI layer — keep the transport behind a
Sendableprotocol so tests can inject a fake stream (anAsyncStreamof messages). - Presence is server-truth. Render "typing…" / "online" from pushed events; do not infer it client-side from request timing.
Related skills
global-skills/apple/swift-clean-architecture-module-scaffold/SKILL.md— theSendabletransport protocol and@MainActorstore isolation this thread depends on.global-skills/apple/swift-liquid-glass-design-system-ios26/SKILL.md— glass bubbles; oneGlassEffectContainerper cluster, never glass-on-glass.global-skills/meta/skill-pattern-freshness-audit/SKILL.md— re-verify scroll/list APIs each WWDC.
Sources
- LazyVStack · PinnedScrollableViews · ScrollPosition · swipeActions(edge:allowsFullSwipe:content:) · URLSessionWebSocketTask
- WWDC23 Beyond scroll views · WWDC23 Discover Observation in SwiftUI
Last verified: 2026-06-03 against Apple Developer docs (ScrollPosition iOS 18+, swipeActions
iOS 15+, URLSessionWebSocketTask iOS 13+ confirmed live). Confirmed there is no Apple chat-UI
framework — these are the canonical building blocks.
Re-check after: WWDC26, or by 2026-12-01. Decay risk: medium (scroll-position APIs modernized
in iOS 18; watch for further changes).
Found a drift? Run /skill-pattern-freshness-audit apple.
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.