Issue #1071
Sooner or later one object needs to tell others that something happened. A music player starts a new track, a download finishes, the network drops. Swift gives us many ways to send that message, and picking the right one depends mostly on one question: how many listeners are there?
All examples below use a small Player that wants to announce PlayerEvent values.
enum PlayerEvent: Sendable {
case started(trackId: String)
case paused
case finished
}One listener: delegation and closures
Delegation is the classic UIKit way. The player holds a weak reference to one delegate and calls it.
protocol PlayerDelegate: AnyObject {
func player(_ player: Player, didSend event: PlayerEvent)
}
final class Player {
weak var delegate: PlayerDelegate?
func play(trackId: String) {
delegate?.player(self, didSend: .started(trackId: trackId))
}
}A closure does the same job with less ceremony. It works well when the listener is the object that created the player, such as a coordinator wiring up a screen.
final class Player {
var onEvent: ((PlayerEvent) -> Void)?
func play(trackId: String) {
onEvent?(.started(trackId: trackId))
}
}
player.onEvent = { [weak self] event in
self?.handle(event)
}Both have the same limit: there is only one slot. If a second object sets onEvent, the first one silently stops hearing anything.
Many listeners: NotificationCenter
NotificationCenter lets anyone post and anyone listen, without knowing about each other.
extension Notification.Name {
static let playerDidFinish = Notification.Name("playerDidFinish")
}
NotificationCenter.default.post(name: .playerDidFinish, object: nil)
let token = NotificationCenter.default.addObserver(
forName: .playerDidFinish,
object: nil,
queue: .main
) { _ in
print("Finished")
}The cost is type safety. The payload travels in a [AnyHashable: Any] dictionary, and any part of the app can post the name. It fits global events like logout. For events owned by one object, it’s too loose.
Many listeners: Combine
Combine gives typed broadcasting. A PassthroughSubject forwards events to every subscriber, and a CurrentValueSubject also remembers the latest value.
import Combine
final class Player {
let events = PassthroughSubject<PlayerEvent, Never>()
func play(trackId: String) {
events.send(.started(trackId: trackId))
}
}
let cancellable = player.events
.receive(on: DispatchQueue.main)
.sink { event in
print(event)
}Combine still works, but Apple has moved its attention to Swift concurrency and Observation. New code tends to reach for the tools below.
State, not events: @Observable and bindings
The @Observable macro (iOS 17) tracks which properties a view reads and re-renders it when they change. Any number of views can read the same model.
import Observation
@Observable
final class Player {
var currentTrackId: String?
var isPlaying = false
}
struct NowPlayingView: View {
let player: Player
var body: some View {
Text(player.currentTrackId ?? "Nothing playing")
}
}@Binding and @Environment then pass that state down the view tree. A child gets read-write access through @Binding, and deeply nested views pull a shared model out of the environment without threading it through every initializer.
Observation is about state. A view sees the latest value, not every step that led to it. If isPlaying flips to true and back to false in the same run loop, nobody sees the true. For one-off events like “track finished”, you want something that delivers each value.
Outside SwiftUI, withObservationTracking fires once per registration, so you have to re-register after each change. On iOS 26, Observations wraps this into an async sequence.
for await isPlaying in Observations({ player.isPlaying }) {
print(isPlaying)
}A typed observer list
When you want typed events, several listeners, and no framework, a small observer list does the job. Each listener registers with an owner that is held weakly, so it goes away when the owner is deallocated.
@MainActor
final class Events<Event> {
private struct Observer {
weak var owner: AnyObject?
let handler: (Event) -> Void
}
private var observers: [Observer] = []
func observe(_ owner: AnyObject, handler: @escaping (Event) -> Void) {
observers.append(Observer(owner: owner, handler: handler))
}
func unobserve(_ owner: AnyObject) {
observers.removeAll { $0.owner === owner }
}
func publish(_ event: Event) {
observers.removeAll { $0.owner == nil }
for observer in observers {
observer.handler(event)
}
}
}The player exposes one Events<PlayerEvent> and every interested screen subscribes.
final class Player {
let events = Events<PlayerEvent>()
func finish() {
events.publish(.finished)
}
}
player.events.observe(self) { [weak self] event in
self?.handle(event)
}It’s around 30 lines, easy to read, and has no hidden scheduling. Handlers run synchronously on the caller’s thread, which is predictable but means a slow handler blocks the publisher.
AsyncStream
With Swift concurrency, an AsyncStream lets a listener consume events with for await. makeStream() (Swift 5.9) returns the stream and its continuation as a pair, so you don’t need to capture the continuation out of a closure.
actor Player {
private var continuation: AsyncStream<PlayerEvent>.Continuation?
func events() -> AsyncStream<PlayerEvent> {
continuation?.finish()
let (stream, continuation) = AsyncStream<PlayerEvent>.makeStream()
self.continuation = continuation
return stream
}
func play(trackId: String) {
continuation?.yield(.started(trackId: trackId))
}
}
let task = Task {
for await event in await player.events() {
print(event)
}
}Cancelling the task ends the loop, so there’s no token or unobserve to forget.
There is a catch. AsyncStream is single-consumer. If two tasks iterate the same stream, each event goes to only one of them. The example above handles this by finishing the old stream when someone asks for a new one, so it’s one-to-one by design. That’s often what you want, for example a socket connection that feeds exactly one screen.
Broadcasting with AsyncStream
To get one-to-many with AsyncStream, give each subscriber its own stream and yield to all of them.
import Synchronization
final class Broadcaster<Event: Sendable>: Sendable {
private let continuations = Mutex<[UUID: AsyncStream<Event>.Continuation]>([:])
func subscribe() -> AsyncStream<Event> {
let (stream, continuation) = AsyncStream<Event>.makeStream()
let id = UUID()
continuations.withLock { $0[id] = continuation }
continuation.onTermination = { [weak self] _ in
self?.continuations.withLock { $0[id] = nil }
}
return stream
}
func send(_ event: Event) {
let current = continuations.withLock { Array($0.values) }
for continuation in current {
continuation.yield(event)
}
}
}onTermination runs when the subscriber’s task is cancelled or stops iterating, which removes it from the dictionary. Mutex needs iOS 18. On older systems, an actor or a lock does the same job.
Each subscriber now gets every event, in order, on its own task. Compared with the observer list, delivery is asynchronous, so a slow subscriber doesn’t block the publisher. By default the stream buffers without limit, so pass a bufferingPolicy such as .bufferingNewest(10) if a subscriber might fall behind.
Which one to pick
For a single listener, a closure is usually enough, and an AsyncStream is a good fit when the listener lives in async code. For state that views render, use @Observable. For discrete events with several listeners, choose between a synchronous observer list and an AsyncStream broadcaster depending on whether your listeners live in callback code or async code. Keep NotificationCenter for the truly global events.
Start the conversation