Baton

Recipes

UIKit and AppKit

A handle held by a controller: the same reads, without SwiftUI.

Baton's reads are synchronous on the main actor and its handles are @Observable classes, so a UIViewController or an NSViewController uses them without SwiftUI: it asks the environment for the handle, holds the retention that keeps the handle's data alive, renders what it reads, and renders again when that changes. Nothing here is a second API; it is the same handle the @Query wrapper resolves.

The code below is compiled in this repository, as the Controllers target under examples/Controllers: what both platforms share in Content.swift, and a list, a detail, a cell and the app's lifecycle for each under UIKit/ and AppKit/. CI builds the UIKit half for the iOS Simulator and the AppKit half for macOS, and ControllersTests proves on macOS what this page says of them.

Where the GraphQL lives#

@Query on a property is a view's storage: it resolves the handle from SwiftUI's environment, which a controller does not have. A controller's operations go in a .graphql file in the target, which the build plugin compiles with the target's Swift sources:

query CharactersQuery($page: Int) {
  characters(page: $page) {
    results {
      id
      ...CharacterCell_character
    }
  }
}

fragment CharacterCell_character on Character {
  name
  status
  species
}

query CharacterQuery($id: ID!) {
  character(id: $id) {
    name
    status
    species
    origin { name }
  }
}

The fragment is here because two cells read it, the UIKit one and the AppKit one. An app with one cell can keep it beside the cell instead: @Fragment is only a marker the compiler reads the text from, so it works on a property of any type, not only a view's.

The handle and its retention#

@MainActor
public final class CharacterViewController: UIViewController {
    private let handle: OperationHandle<CharacterQuery>
    private var retention: Retention?
    private var observation: Task<Void, Never>?

    public init(environment: Environment, id: String) {
        handle = environment.handle(for: CharacterQuery(id: id))
        super.init(nibName: nil, bundle: nil)
    }

    isolated deinit {
        observation?.cancel()
    }
}

handle(for:) returns the one handle every holder of an equal operation value shares, and attaches with the fetch policy given (storeOrNetwork by default). retain(), called when the view loads, returns a Retention: while the controller holds it, the operation's root is the store's and its records stay; when the controller goes, so does the retention, and the collector may reclaim what nothing else keeps. Hold it in a property, never in a local. The AppKit controller is the same, over NSViewController.

Rendering on change#

The handle's phase is .loading, .ready(data) or .failed(error), and a lens's fields are observable. Observations, on the 26 floor, turns what a closure reads into a sequence that yields when any of it changes. What it watches is what the closure reads, and nothing else: the phase reads whether the data is there, not the fields in it. So the closure computes what the controller shows, reading every field it displays, and the loop only applies it. The computation is plain Swift over the lens, the same on both platforms:

public enum CharacterContent: Sendable, Equatable {
    case loading
    case failed(String)
    case ready(name: String, details: String, origin: String)

    @MainActor
    public init(_ phase: Phase<CharacterQuery.Data>) {
        switch phase {
        case .loading:
            self = .loading
        case .failed(let error):
            self = .failed(String(describing: error))
        case .ready(let data):
            guard let character = data.character else {
                self = .failed("No such character")
                return
            }
            self = .ready(
                name: character.name ?? "Unknown",
                details: [character.status, character.species].compactMap { $0 }.joined(separator: " · "),
                origin: character.origin?.name ?? "Unknown"
            )
        }
    }
}

and the controller observes it:

public override func viewDidLoad() {
    super.viewDidLoad()
    retention = handle.retain()
    render(CharacterContent(handle.phase))
    observation = Task { [weak self, handle] in
        for await content in Observations({ CharacterContent(handle.phase) }) {
            self?.render(content)
        }
    }
}

A commit that renames the character yields once; a commit that changes a field the controller does not show yields nothing. A loop that observed handle.phase alone and read the fields in its body would render the first response and never the rename.

The first render call is not redundant. Observations is a sequence and delivers its first value after a suspension, so a controller that waited for it would show one blank frame even when the store already holds the data; the synchronous read draws that frame from the store. The sequence's first value then renders the same thing again.

retry() on the handle after a failed phase, refetch() behind a refresh control, and isStale to decide whether to show one are the handle's, as they are a view's.

A cell#

A cell takes a lens, not a model: the fragment's generated struct, handed down by the controller as a parent view hands it to a child. The list's content reads the rows' ids and lenses, not their fields, so a commit that renames one character renders that character's cell and does not reload the table.

@MainActor
public final class CharacterCell: UITableViewCell {
    private var observation: Task<Void, Never>?

    public func bind(_ character: CharacterCell_character) {
        observation?.cancel()
        render(CharacterCellContent(character))
        observation = Task { [weak self] in
            for await content in Observations({ CharacterCellContent(character) }) {
                self?.render(content)
            }
        }
    }

    public override func prepareForReuse() {
        super.prepareForReuse()
        observation?.cancel()
        observation = nil
    }

    private func render(_ content: CharacterCellContent) {
        var configuration = defaultContentConfiguration()
        configuration.text = content.name
        configuration.secondaryText = content.details
        contentConfiguration = configuration
    }
}

The lens is a value over the store; it reads synchronously and costs nothing to hold, so a bound cell renders in the call. The cell cancels its observation on reuse so a recycled cell does not render the row it left. The AppKit cell is an NSTableCellView with the same bind and prepareForReuse, rendering into two labels.

The app's lifecycle#

environment.isActive parks the retained subscriptions while it is false and resumes them when it is true again; environment.revalidate() refetches the retained operations that went stale or failed. Each platform says when from its own notifications, and the app holds one Activation for as long as the environment lives.

On iOS the application's notifications, not a scene's: the environment is the app's, and one scene leaving the screen while another stays does not make the app inactive.

observers = [
    center.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { _ in
        MainActor.assumeIsolated { environment.isActive = false }
    },
    center.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: .main) { _ in
        MainActor.assumeIsolated {
            environment.isActive = true
            environment.revalidate()
        }
    },
]

On macOS a window stays on screen while another app is frontmost, so resigning active parks nothing: hiding does, and returning revalidates.

observers = [
    center.addObserver(forName: NSApplication.didHideNotification, object: nil, queue: .main) { _ in
        MainActor.assumeIsolated { environment.isActive = false }
    },
    center.addObserver(forName: NSApplication.didUnhideNotification, object: nil, queue: .main) { _ in
        MainActor.assumeIsolated { environment.isActive = true }
    },
    center.addObserver(forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main) { _ in
        MainActor.assumeIsolated { environment.revalidate() }
    },
]

Writes are the environment's whatever hosts them: try await environment.mutate(SetFavorite(id: id, favorite: true), optimistic: ...) from an action, and the optimistic layer shows in the turn of the call.

Nothing in the runtime imports UIKit or AppKit; Observations and the handles are Foundation and Observation.

The Kotlin wiring, an Android app's process lifecycle told to the environment, is in views.md.