Module 4

SwiftUI

The declarative model

A View is a struct with a body property describing what should appear for the current state. You never mutate the UI; you change state and let SwiftUI recompute.

struct Waveform: View {
    let levels: [Float]

    var body: some View {
        HStack(spacing: 2.5) {
            ForEach(0..<5, id: \.self) { index in
                Capsule().frame(width: 3, height: height(for: index))
            }
        }
    }
}

some View means "a specific concrete type I'm not going to spell out" — an opaque return type. It exists because SwiftUI's real types are absurd (HStack<TupleView<(Capsule, Text)>> and worse) and nobody should have to write them. Just write some View.

Views are cheap and disposable. SwiftUI creates and destroys them constantly — many times per second during animation. They are descriptions, not objects. This is why they can't hold mutable state directly, and why @State exists.

State ownership

Choosing the right state tool is most of what makes SwiftUI feel good or awful. There are four you actually need.

ToolMeaningUse when
@StateThis view owns this valueLocal UI state — a text field's contents, a toggle
@BindingRead/write access to state owned elsewhereA child needs to mutate the parent's value
@Observable classShared reference-type modelApp logic, controllers, stores
@EnvironmentImplicit dependency injection down the treeTheme, locale, shared services

The pattern in Spoke: @State private var controller = ControllerBox() at the app level creates and owns the controller for the app's lifetime. Views read controller.state and automatically re-render when it changes, because @Observable tracks the read.

$ gets you a binding: TextField("Add a term", text: $newTerm) passes read/write access to newTerm, so the text field can mutate it.

From Flutter/RN

@State is useState. @Binding is passing [value, setValue] down as props. @Observable is your store. @Environment is React Context or Flutter's InheritedWidget. The mapping is unusually clean.

Modifiers and layout

Modifiers wrap a view in another view. .padding() doesn't set a property — it returns a new view containing the original with padding around it. Which means order matters:

Text("Hi").padding().background(.blue)   // blue includes the padding
Text("Hi").background(.blue).padding()   // blue hugs the text only

SwiftUI's layout is a three-step negotiation, and internalising it removes almost all layout confusion:

  1. The parent proposes a size to the child.
  2. The child chooses its own size — it is under no obligation to accept.
  3. The parent positions the child.

Parents cannot force sizes. That's why .frame() is itself a view that proposes a fixed size to its child, rather than a property that sets one. And it's why Spacer() works: it's a view that greedily accepts all offered space.

In OverlayView, .frame(maxWidth: 320, alignment: .leading) combined with .fixedSize(horizontal: false, vertical: true) says: cap my width at 320, but let me be as tall as I need. That's the standard incantation for text that wraps rather than truncates.

Scenes and app structure

A Scene is a container for UI at the OS level — a window, a settings panel, a menu bar item. App is a protocol whose body returns scenes.

@main
struct SpokeApp: App {
    var body: some Scene {
        MenuBarExtra {
            MenuContent()
        } label: {
            Image(systemName: "mic")
        }

        Settings { SettingsView() }
    }
}

@main is the entry point — no main(), no runApp(). The macro generates the bootstrap.

Scene types you'll meet: WindowGroup (normal windows, user can open several), Window (exactly one), Settings (the standard ⌘, panel, free menu item included), and MenuBarExtra (a menu bar item). Spoke uses only the last two — it has no main window at all, which is what makes it feel like a utility rather than an app you have to manage.

Spoke never sends your voice, your text, or anything else off this Mac. Not a promise in a privacy policy — a property of the binary.