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.
| Tool | Meaning | Use when |
|---|---|---|
@State | This view owns this value | Local UI state — a text field's contents, a toggle |
@Binding | Read/write access to state owned elsewhere | A child needs to mutate the parent's value |
@Observable class | Shared reference-type model | App logic, controllers, stores |
@Environment | Implicit dependency injection down the tree | Theme, 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:
- The parent proposes a size to the child.
- The child chooses its own size — it is under no obligation to accept.
- 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.