Activity queue
NookActivityQueue collects transient activities - “build finished”, “backup
complete”, “new message” - orders them by priority, coalesces duplicates, and
presents each by briefly taking over the expanded surface. It lives in the
NookComponents product, so depend on that alongside NookApp.
When to use it
- You need to announce something time-limited and then go back to whatever was on screen.
- You expect bursts: several activities arriving at once, of varying urgency, some of which collapse into “latest wins”.
- You want to coexist with the user. If the user is hovering or has the nook open, the queue should yield - not stomp on what they were doing.
If you need a permanent glance (a clock, a volume meter), use a compact-slot view instead - the queue is for transient takeovers.
Minimal setup
import NookAppimport NookComponentsimport SwiftUI
NookApp.main { let queue = NookActivityQueue()
var configuration = NookConfiguration() configuration.setHome { NookActivityHost(queue: queue) { MyNormalHomeView() } } configuration.onReady = { coordinator in queue.bind(to: coordinator) } return configuration}Three wires:
- Build a
NookActivityQueueon the main actor (the builder closure runs there). - Wrap your normal home view in
NookActivityHostso the queue’s current activity card replaces it during a takeover and falls back to your content when idle. - Bind the queue to the coordinator from
onReadyso the queue can drive the surface.
Then enqueue from anywhere on the main actor:
queue.enqueue(NookActivity( priority: .normal, title: "Build finished", subtitle: "Debug \u{B7} 12.4s", systemImage: "hammer", tint: .green))The full working example is at Examples/ActivityNook/main.swift. That
example wraps the queue in a full NookModule so it can implement
prepareForSwitchAway - the pattern any multi-module host needs (see below).
Key types
NookActivity
The model. Identifiable, Sendable, and cheap to build:
public struct NookActivity: Identifiable, Sendable { public let id: UUID public var coalescingKey: String? public var priority: NookActivityPriority // .low / .normal / .high public var title: String public var subtitle: String? public var systemImage: String? // SF Symbol public var tint: Color public var dwell: Duration // default 2.4s}NookActivityQueue
The driver. Holds @Published current (the activity on screen, or nil),
@Published pending (the queue), and @Published isSuspended. It is
@MainActor-isolated; all mutation happens on the main actor.
queue.enqueue(_:) // add an activityqueue.cancel(_:) // remove a still-pending activityqueue.cancelAll(coalescingKey:)queue.suspend() / resume() // cooperative pauseawait queue.quiesce() // hard teardown (joins + releases surface)NookActivityHost
The home-view wrapper. Renders the default NookActivityCard while the queue
has a current, your normal content otherwise. Reads
\.nookResolvedTheme from the environment, so the card inherits your chrome
palette without extra wiring.
Priority model
NookActivityPriority is a three-step ordering:
.low- background cue. Ambient signal..normal- ordinary activity from the foreground module. The default..high- time-sensitive.
When the queue is ready to present the next activity, it drains the highest
priority first. Within a priority class, the queue keeps FIFO order: a .normal
enqueued first goes on screen first, even if a second .normal arrives during
its dwell. A .high that arrives while the queue is parked on a denied claim
still preempts (the next dequeue pass picks it ahead of any .normal).
Priority is also what gates a backgrounded module’s claims. In a multi-module
host, only a .high claim from a non-foreground module can take over the
surface from the foreground one - lower priorities are denied at the arbiter.
This is the seam that lets background modules surface their own urgent events
without quietly stealing the surface for ambient ones.
Coalescing
Set coalescingKey on activities that should “keep latest”:
queue.enqueue(NookActivity( coalescingKey: "build", priority: .normal, title: "Build finished", subtitle: "Debug \u{B7} 12.4s"))Enqueueing an activity with a non-nil key removes any pending peer that
shares the key first. Coalescing only affects what is queued - an activity
already on screen finishes its dwell and is replaced by the new one
afterwards. nil (the default) means “never coalesce”.
Common shapes for the key:
- A bare event identifier (
"build") for “show me the freshest build result”. - A composite (
"build:\(target)") for “freshest result per target”.
cancelAll(coalescingKey:) is the manual companion: drop everything pending
under a given key without enqueuing a replacement.
Yield to the user
The queue does not fight the user for the surface. Every drain iteration checks whether the user is engaged - hovering the nook, or having opened it themselves - and parks if so. The poll interval is 200 ms, so a disengaged user sees the next activity without a perceptible gap.
This is yield, not preempt. The queue:
- Waits to take the surface while the user is engaged.
- Does not interrupt a takeover the user starts mid-dwell - the running activity finishes its dwell, but no new claim is made while the user stays engaged.
Cooperative pause vs hard teardown:
suspend()pauses new takeovers. An activity already on screen finishes its dwell and releases its surface claim normally; the engagement-yield poll exits on the next tick. Pair withresume().await quiesce()does suspend plus await - the drain task fully unwinds and any held surface token is released before this returns. Use this when you need to prove the queue is no longer touching the surface (module switch-away, queue destruction). Idempotent.
Module switch-away: the quiesce contract
A module that owns a NookActivityQueue must drain it before being switched
away, or its dangling surface claim can outlive the switch. The framework
gives you exactly one seam for this: NookModule.prepareForSwitchAway.
@MainActorfinal class ActivityModule: NookModule { private let queue = NookActivityQueue()
func makeConfiguration() -> NookConfiguration { var configuration = NookConfiguration() configuration.setHome { NookActivityHost(queue: self.queue) { ActivityNookHome() } } configuration.onReady = { [queue, descriptor] coordinator in queue.bind(to: coordinator, moduleID: descriptor.id) } return configuration }
func prepareForSwitchAway() async { await queue.quiesce() }}A few details that matter:
- Pass the module’s descriptor id to
bind(to:moduleID:)in a multi-module host. The queue stamps it onto every claim so the arbiter can gate a background module’s activities correctly. A single-module host can omit it - the queue falls back to the active module at bind time. prepareForSwitchAwayis async;onDeactivateis synchronous. Use the async seam when you need to join in-flight work, the sync seam for cheap cleanup (timers, observers).- The framework bounds
prepareForSwitchAwayto a 2-second timeout. The surface arbiter already treats the outgoing module’s claims as stale, so a misbehaving drain can’t strand the user-visible switch - but you should not rely on this in normal code.
See the Multiple modules guide for the full switch sequence.
Pitfalls
Don’t preempt an in-flight activity by priority
.high does not interrupt a .normal that is already on screen - priority
orders only what is still pending. If you absolutely need to replace what’s
showing, cancel via id and let the drain pick the new one, or build a
short-dwell activity so it ends quickly.
Hard teardown vs cooperative pause
A cancel() on the drain task mid-dwell would skip the
endTransientPresentation call and strand the arbiter claim until the next
quiesce released it. suspend() is cooperative for this reason. Use
quiesce() (not suspend()) when you mean “stop, and be done with the
surface.”
Multi-module hosts: stamp the right module id
In a multi-module host, bind(to: coordinator) with no moduleID reads the
foreground module at bind time. That is fine for a single-module host but
can stamp the wrong identity onto claims if the binding happens while the
owning module is in the background. Pass moduleID: descriptor.id explicitly
from a module’s onReady.
See also
Examples/ActivityNook/main.swift- the working module pattern (includingprepareForSwitchAway) this guide mirrors.- Multiple modules - the switch lifecycle the quiesce contract plugs into.
Sources/NookComponents/Activities/- the queue, model, and host view sources.