App Architecture the Apple Way
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
This page is about where code goes in an Apple-platforms app: which types own the data, how views find that data, how services are replaced in tests and previews, and how names are chosen so the code reads well where it is called. It targets the section’s baseline: iOS, iPadOS, macOS, watchOS and visionOS 27, built with Xcode 27 and Swift 6.4, with a deployment minimum of 26 for every FieldNotes target. Nothing here needs an API newer than 26 except where a paragraph says so. The running example is the same FieldNotes app used across the section. Getting Started with Apple Platforms introduces it, and Swift Packages and Modularizing an App shows its package layout. This page shows how its pieces fit together.
The language features used here (the @Observable macro, property wrappers, protocols, structures and classes)
are explained in the Swift reference and are not repeated. See
Properties (section The Observation Module’s @Observable),
Protocols and
Structures and Classes. For general architecture
theory (layers, domain modelling, repositories, CQRS), see
Architectural Patterns; this page only covers
what is specific to SwiftUI apps.
The Shape Apple’s Documentation Recommends
Apple’s article Managing model data in your app and the WWDC sessions Data Essentials in SwiftUI (2020) and Discover Observation in SwiftUI (2023) describe one consistent shape, often called "MV" (model and view):
-
Model data lives in observable types. A class marked
@Observableholds the app’s data and the operations that change it. SwiftUI tracks which properties each view’sbodyreads and redraws only those views when those properties change. -
Views are a function of that data. A view reads model properties directly. Nothing has to forward them through a separate "presentation" object.
-
Ownership is explicit. The view (or
App) that creates a model holds it in@State, so SwiftUI keeps the same instance alive for the lifetime of that view. -
Sharing goes through the environment. A model that many views need is placed in the environment once, with
.environment(model), and read wherever it is needed with@Environment(Model.self). Nothing has to pass it through every initializer. -
Services are dependencies, not globals. Networking, persistence and on-device intelligence are reached through protocols. The real implementation is injected at the top, and a fake one is injected in tests and previews.
FieldNotes follows this shape. One @Observable store, NoteStore, owns the notes and notebooks. The App owns
the store. Every screen reads it from the environment, and the store talks to the outside world only through the
NoteSyncing and SummaryGenerating protocols.
Figure: the FieldNotes object graph
The arrows show ownership and data flow, not inheritance. Only FieldNotesApp owns the store. Every other
view observes it, and services sit behind protocols at the edge of the graph.
The Model Layer: Plain Value Types
The domain types live in the FieldNotesModel module of the local FieldNotesKit package. They are structures:
a note is a value, and two notes with the same fields are the same note. They know nothing about SwiftUI. On
Persisting Models with SwiftData they become persistent models; here they are plain Swift values.
// Packages/FieldNotesKit/Sources/FieldNotesModel/Note.swift
import Foundation
public struct Note: Identifiable, Hashable, Sendable {
public var id: UUID
public var title: String
public var body: String
public var createdAt: Date
public var notebookID: Notebook.ID?
public var tags: [String]
public var isPinned: Bool
public init(id: UUID = UUID(), title: String, body: String = "", createdAt: Date = .now,
notebookID: Notebook.ID? = nil, tags: [String] = [], isPinned: Bool = false) {
self.id = id; self.title = title; self.body = body; self.createdAt = createdAt
self.notebookID = notebookID; self.tags = tags; self.isPinned = isPinned
}
}
public struct Notebook: Identifiable, Hashable, Sendable {
public var id: UUID
public var name: String
public var symbolName: String
public init(id: UUID = UUID(), name: String, symbolName: String = "book.closed") {
self.id = id; self.name = name; self.symbolName = symbolName
}
}
Because these types are public and cross a module boundary, Sendable is declared explicitly: the compiler
infers it only for non-public types. The explicit conformance lets notes cross between the main actor and
background work freely, as shown on Swift Concurrency in Apps.
Observable Models: NoteStore
NoteStore is the one reference type at the heart of the app. It is @Observable, so SwiftUI tracks reads of
notes and notebooks. It is @MainActor because views read it and it is mutated in response to UI events.
Apps created with Xcode 26 or later already default to main-actor isolation in the app target, but this type
lives in a package, which does not get that Xcode setting, so the attribute is written out.
// Packages/FieldNotesKit/Sources/FieldNotesServices/NoteStore.swift
import Observation
import FieldNotesModel
@Observable
@MainActor
public final class NoteStore {
public var notes: [Note]
public var notebooks: [Notebook]
@ObservationIgnored private let syncer: any NoteSyncing
public init(notes: [Note] = [], notebooks: [Notebook] = [],
syncer: any NoteSyncing = NoOpNoteSyncer()) {
self.notes = notes
self.notebooks = notebooks
self.syncer = syncer
}
public func add(_ note: Note) {
notes.append(note)
}
public func delete(_ note: Note) {
notes.removeAll { $0.id == note.id }
}
public func togglePin(_ note: Note) {
guard let index = notes.firstIndex(where: { $0.id == note.id }) else { return }
notes[index].isPinned.toggle()
}
public func refresh() async throws {
try await syncer.sync()
}
}
A few details matter:
-
@ObservationIgnoredexcludes the service reference from tracking. It never changes, and no view should redraw because of it. -
The mutating operations (
add,delete,togglePin) live on the store, not in views. A view says what happened ("the user pinned this note") and the store decides how the data changes. This is what keeps views small and makes the rules testable without a UI. -
notes[index].isPinned.toggle()mutates a value inside an array held by a reference type. Observation sees one change tonotes, and every view that readnotesupdates.
Apps written before iOS 17 used ObservableObject, @Published and @StateObject. Apple’s migration article
maps each piece one-to-one. The short version is that @Observable replaces the protocol and @Published,
@State replaces @StateObject, and @Environment(Type.self) replaces @EnvironmentObject:
// Before: Combine-era observable object (still compiles, but not how FieldNotes is written).
final class LegacyNoteStore: ObservableObject {
@Published var notes: [Note] = []
}
// After: the Observation macro; only properties a view actually reads trigger updates.
@Observable
final class MigratedNoteStore {
var notes: [Note] = []
}
Owning a Model with @State
Whoever creates an observable model owns it, and owning means holding it in @State. For the app-wide store
that owner is the App itself:
// FieldNotes/FieldNotesApp.swift
import SwiftUI
import FieldNotesServices
@main
struct FieldNotesApp: App {
@State private var store = NoteStore(syncer: CloudNoteSyncer())
var body: some Scene {
WindowGroup {
ContentView()
.environment(store)
}
}
}
@State tells SwiftUI to create the storage once and keep it across re-evaluations of the owning view. In
Xcode 27, @State is implemented as a macro (State()), so the initial value expression runs only once. In
earlier releases a class stored in @State could be constructed again every time the parent view rebuilt the
child, and SwiftUI then discarded the new copy. Code that relied on side effects in a model’s initializer behaved
differently because of that. With the 27 SDK this pitfall disappears; with a deployment minimum of 26, the
behaviour depends on the toolchain the app is built with, not the OS it runs on. Keep model initializers free
of side effects either way. State and Data Flow with
Observation covers the macro in detail.
View-Local State Is Still @State
Not every piece of state belongs in the store. The search text, whether a sheet is open, and which row is
selected describe this view on this screen, not the user’s data. They stay in the view as private @State:
// FieldNotes/Views/NoteList.swift
import SwiftUI
import FieldNotesModel
import FieldNotesServices
struct NoteList: View {
@Environment(NoteStore.self) private var store
@State private var searchText = ""
@State private var isComposing = false
let notebookID: Notebook.ID?
private var visibleNotes: [Note] {
store.notes.filter { note in
note.notebookID == notebookID
&& (searchText.isEmpty || note.title.localizedStandardContains(searchText))
}
}
var body: some View {
List(visibleNotes) { note in
NoteRow(note: note)
.swipeActions {
Button(note.isPinned ? "Unpin" : "Pin") { store.togglePin(note) }
}
}
.searchable(text: $searchText)
.toolbar {
Button("New Note", systemImage: "square.and.pencil") { isComposing = true }
}
.sheet(isPresented: $isComposing) {
NoteComposer(notebookID: notebookID)
}
}
}
A useful rule is to ask whether the value would survive the view disappearing. If a note is pinned, it should
stay pinned after the list scrolls away, so that belongs in the model. If the search field is cleared when the
user leaves the screen, that is fine, so it stays in view state. To keep view state across launches, use
@SceneStorage (see App Structure and Life Cycle (SwiftUI, UIKit, AppKit)).
Sharing Through the Environment: .environment(_:) and @Environment(Type.self)
.environment(store) in FieldNotesApp places the store in the environment of every view below it. Any view
reads it by type, without an initializer parameter:
// FieldNotes/Views/NotebookList.swift
import SwiftUI
import FieldNotesModel
import FieldNotesServices
struct NotebookList: View {
@Environment(NoteStore.self) private var store
@Binding var selection: Notebook.ID?
var body: some View {
List(store.notebooks, selection: $selection) { notebook in
Label(notebook.name, systemImage: notebook.symbolName)
.badge(store.notes.count { $0.notebookID == notebook.id })
}
.navigationTitle("Notebooks")
}
}
See Apple Developer Documentation -- environment(_:) and Apple Developer Documentation — Environment.
Two properties of this mechanism shape the architecture:
-
Reading a missing object crashes.
@Environment(NoteStore.self)is non-optional, so a view used without an ancestor that called.environment(store)traps at runtime. Previews and tests must inject the store too, as shown below. Declare the property as@Environment(NoteStore.self) private var store: NoteStore?only when the object is truly optional. -
Observation still applies.
NotebookListreadsstore.notebooksandstore.notes, so it redraws when either changes, and not when some unrelated property of the store changes.
The environment is for shared dependencies. A value that one parent hands to one child, such as the Note a
row displays, is still an ordinary initializer parameter. Parameters make a view’s inputs explicit, and the
environment is for everything that is ambient.
Editing Through Bindings: @Bindable
Controls such as TextField and Toggle need a Binding. An @Observable object read from the environment or
passed in as a plain property doesn’t produce bindings on its own. @Bindable adds that projection ($).
FieldNotes uses it in two places.
The first is inside a body, for an object that came from the environment:
// FieldNotes/Views/NoteEditor.swift
import SwiftUI
import FieldNotesModel
import FieldNotesServices
struct NoteEditor: View {
@Environment(NoteStore.self) private var store
let noteID: Note.ID
var body: some View {
@Bindable var store = store
if let index = store.notes.firstIndex(where: { $0.id == noteID }) {
Form {
TextField("Title", text: $store.notes[index].title)
TextEditor(text: $store.notes[index].body)
Toggle("Pinned", isOn: $store.notes[index].isPinned)
}
} else {
ContentUnavailableView("No Note Selected", systemImage: "note.text")
}
}
}
The second is as a stored property, for a small observable draft object that a sheet owns with @State and
hands to a form. The draft keeps unsaved edits out of the store until the user taps Save:
// FieldNotes/Views/NoteComposer.swift
import SwiftUI
import FieldNotesModel
import FieldNotesServices
@Observable
final class NoteDraft {
var title = ""
var body = ""
var tags: [String] = []
}
struct NoteComposer: View {
@Environment(NoteStore.self) private var store
@Environment(\.dismiss) private var dismiss
@State private var draft = NoteDraft()
let notebookID: Notebook.ID?
var body: some View {
NavigationStack {
NoteDraftForm(draft: draft)
.toolbar {
Button("Save") {
store.add(Note(title: draft.title, body: draft.body,
notebookID: notebookID, tags: draft.tags))
dismiss()
}
.disabled(draft.title.isEmpty)
}
}
}
}
struct NoteDraftForm: View {
@Bindable var draft: NoteDraft
var body: some View {
Form {
TextField("Title", text: $draft.title)
TextField("Body", text: $draft.body, axis: .vertical)
}
}
}
NoteDraftForm doesn’t own the draft, so it uses @Bindable rather than @State. If it declared the draft as
@State, it would own a copy of the reference, and a parent that replaced the draft would not be seen.
Custom Environment Values with @Entry
Services that are not observable models, such as the summary generator, are injected as environment values.
Declaring a custom value used to take an EnvironmentKey type plus a computed property. The @Entry macro
generates both from one line:
// FieldNotes/Environment/EnvironmentValues+Services.swift
import SwiftUI
import FieldNotesModel
import FieldNotesServices
extension EnvironmentValues {
@Entry var summaryGenerator: any SummaryGenerating = UnavailableSummaryGenerator()
}
// FieldNotesServices: the fallback used when nothing else is injected.
public struct UnavailableSummaryGenerator: SummaryGenerating {
public init() {}
public func summary(for note: Note) async throws -> String {
throw SummaryError.unavailable
}
}
public enum SummaryError: Error { case unavailable }
The app injects the real implementation once, next to the store. A view reads it by key path. Here the detail screen offers a Summarize button:
// FieldNotesApp.body -- the real generator is implemented with Foundation Models on page 29.
WindowGroup {
ContentView()
.environment(store)
.environment(\.summaryGenerator, FoundationModelSummaryGenerator())
}
// FieldNotes/Views/NoteDetail.swift
struct NoteDetail: View {
@Environment(\.summaryGenerator) private var summaryGenerator
@State private var summary: String?
let note: Note
var body: some View {
ScrollView {
Text(note.body)
if let summary { Text(summary).font(.callout).foregroundStyle(.secondary) }
}
.toolbar {
Button("Summarize", systemImage: "sparkles") {
Task { summary = try? await summaryGenerator.summary(for: note) }
}
}
}
}
FoundationModelSummaryGenerator is written on
Apple Intelligence and Machine Learning. Because
the view depends only on the protocol, this page, previews and tests never need the model to be available.
Protocol-Typed Services and Test Doubles
FieldNotes injects its two services in two different ways, and both are legitimate:
| Service | Injection | Why |
|---|---|---|
|
Through |
The store uses it, not views; the store’s tests construct it directly. |
|
As an |
Views use it directly; previews swap it with one modifier. |
The protocols are the ones the whole section uses:
// Packages/FieldNotesKit/Sources/FieldNotesServices/Services.swift
import FieldNotesModel
public protocol NoteSyncing: Sendable {
func sync() async throws
}
public protocol SummaryGenerating {
func summary(for note: Note) async throws -> String
}
public struct NoOpNoteSyncer: NoteSyncing {
public init() {}
public func sync() async throws {}
}
NoteSyncing refines Sendable because the syncer is stored in a main-actor store but does its work elsewhere,
typically in an actor. SummaryGenerating is called only from main-actor views and does not need to be.
A test double is just another conforming type. An actor is a convenient one because it records calls safely. The test below uses Swift Testing, which Unit Testing with Swift Testing covers in full:
// FieldNotesTests/NoteStoreTests.swift
import Testing
import FieldNotesModel
import FieldNotesServices
actor RecordingNoteSyncer: NoteSyncing {
private(set) var syncCount = 0
func sync() async throws { syncCount += 1 }
}
@MainActor
struct NoteStoreTests {
@Test func refreshSyncsExactlyOnce() async throws {
let syncer = RecordingNoteSyncer()
let store = NoteStore(syncer: syncer)
try await store.refresh()
let count = await syncer.syncCount
#expect(count == 1)
}
@Test func togglePinFlipsOnlyThatNote() {
let pinned = Note(title: "Heron", isPinned: true)
let other = Note(title: "Kingfisher")
let store = NoteStore(notes: [pinned, other])
store.togglePin(pinned)
#expect(store.notes.map(\.isPinned) == [false, false])
}
}
Previews use the same seam. A preview gets a store full of sample data and a summary generator that answers instantly, so the canvas never touches the network or the on-device model:
// FieldNotes/Preview Content/PreviewSupport.swift
import SwiftUI
import FieldNotesModel
import FieldNotesServices
struct CannedSummaryGenerator: SummaryGenerating {
func summary(for note: Note) async throws -> String { "A short summary of \(note.title)." }
}
extension NoteStore {
static var preview: NoteStore {
NoteStore(notes: [
Note(title: "Heron at the lake", tags: ["birds"], isPinned: true),
Note(title: "Moss on the north wall", tags: ["plants"]),
])
}
}
#Preview {
NavigationStack {
NoteDetail(note: NoteStore.preview.notes[0])
}
.environment(NoteStore.preview)
.environment(\.summaryGenerator, CannedSummaryGenerator())
}
MV with Observation vs MVVM: a Fair Comparison
Many teams arrive at SwiftUI from UIKit or Android with Model-View-ViewModel habits, and the debate is often
framed as "SwiftUI doesn’t need view models". The accurate statement is narrower. With Observation, a view model
is just another @Observable type, and SwiftUI treats it exactly like NoteStore. The framework has no
special view-model concept to adopt or reject. The real question is whether a screen has enough logic of its own
to deserve a separate, testable type.
Figure: MV with Observation vs MVVM
@Observable, app-wide"] -->|"@Environment(NoteStore.self)"| V1["NoteList
@State searchText
filters in a computed property"] V1 -->|"store.togglePin(note)"| S1 end subgraph MVVM["MVVM with Observation"] direction TB S2["NoteStore
@Observable, app-wide"] -->|"notes passed in"| VM["NoteListViewModel
@Observable, per screen
searchText, showsPinnedOnly"] VM -->|"visibleNotes"| V2["NoteList
@State viewModel"] V2 -->|"$viewModel.searchText"| VM V2 -->|"store.togglePin(note)"| S2 end
The same NoteList written the MVVM way moves filtering state and logic into a per-screen observable type:
// FieldNotes/Views/NoteListViewModel.swift
import Observation
import FieldNotesModel
@Observable
final class NoteListViewModel {
var searchText = ""
var showsPinnedOnly = false
func visibleNotes(from notes: [Note], in notebookID: Notebook.ID?) -> [Note] {
notes.filter { note in
note.notebookID == notebookID
&& (!showsPinnedOnly || note.isPinned)
&& (searchText.isEmpty || note.title.localizedStandardContains(searchText))
}
}
}
struct NoteListMVVM: View {
@Environment(NoteStore.self) private var store
@State private var viewModel = NoteListViewModel()
let notebookID: Notebook.ID?
var body: some View {
List(viewModel.visibleNotes(from: store.notes, in: notebookID)) { note in
NoteRow(note: note)
}
.searchable(text: $viewModel.searchText)
.toolbar { Toggle("Pinned Only", isOn: $viewModel.showsPinnedOnly) }
}
}
Compared honestly:
| Concern | MV with Observation | MVVM with Observation |
|---|---|---|
Where screen logic lives |
Computed properties and small helpers on the view, or on the model |
A per-screen |
Testing screen logic |
Test the model; view helpers are tested through previews or UI tests |
Unit-test the view model directly, with no SwiftUI involved |
Amount of code |
Least; nothing to forward |
One more type per screen; risk of "pass-through" properties |
Update granularity |
Identical — Observation tracks property reads in both cases |
Identical |
Good fit |
Screens that mostly display and edit shared data |
Screens with real state machines: multi-step flows, validation, paging |
FieldNotes uses MV by default and introduces a view model only where a screen has logic worth testing on its own. The anti-pattern to avoid is a view model that merely re-exposes store properties one by one. It doubles the code and adds a place for the two copies to disagree. For the broader pattern vocabulary, see Architectural Patterns.
Value Types vs Reference Types
Swift’s split between structures and classes (explained in Structures and Classes) maps directly onto app architecture:
-
Values for data.
NoteandNotebookare structs. Copying one produces an independent value, so a note passed toNoteRowcan’t be mutated behind the row’s back, and values cross concurrency domains safely. -
References for identity and shared mutable state.
NoteStoreis a class because there must be exactly one store that every view observes. Two copies would be two separate sources of truth. -
Views are values too. A SwiftUI
Viewis a lightweight struct describing UI. SwiftUI owns the real, long-lived state (@State), which is why a view never needs to be a class.
The consequence shows up in how data is edited. A view that receives a Note value cannot "save" it by mutating
its copy. It asks the store to change the canonical value:
// FieldNotes/Views/NoteRow.swift
import SwiftUI
import FieldNotesModel
import FieldNotesServices
struct NoteRow: View {
@Environment(NoteStore.self) private var store
let note: Note // a value: this row's snapshot
var body: some View {
HStack {
Text(note.title)
Spacer()
Button(note.isPinned ? "Unpin" : "Pin",
systemImage: note.isPinned ? "pin.fill" : "pin") {
store.togglePin(note) // the store mutates the one canonical copy
}
.labelStyle(.iconOnly)
}
}
}
When a type needs identity and is part of the data, as a SwiftData @Model does, it becomes a class. That is
a persistence decision covered on Persisting Models with SwiftData, not a reason to make every model a
class.
Feature Modularization
The architecture above maps onto modules. Types go in FieldNotesModel, the store and service protocols go in
FieldNotesServices, shared views go in FieldNotesUI, and the app target only assembles them and injects the
real services. This keeps build times down, makes access control meaningful, and lets the watchOS app and the
widgets reuse the same model and services:
// swift-tools-version: 6.4
// Packages/FieldNotesKit/Package.swift
import PackageDescription
let package = Package(
name: "FieldNotesKit",
platforms: [.iOS(.v26), .macOS(.v26), .watchOS(.v26), .visionOS(.v26)],
products: [
.library(name: "FieldNotesModel", targets: ["FieldNotesModel"]),
.library(name: "FieldNotesServices", targets: ["FieldNotesServices"]),
],
targets: [
.target(name: "FieldNotesModel"),
.target(name: "FieldNotesServices", dependencies: ["FieldNotesModel"]),
]
)
The dependency direction is the architectural rule. FieldNotesModel imports nothing from the app,
FieldNotesServices imports only the model, and only the app target knows which concrete services exist.
Swift Packages and Modularizing an App explains the
package mechanics, and One Codebase for iOS, iPadOS, macOS, watchOS
and visionOS shows how the same modules serve every platform target.
The Swift API Design Guidelines in App Code
Apple’s frameworks, and the Swift standard library, follow the Swift API Design Guidelines. App code that follows them reads like the frameworks it calls. The language-level summary is on Lexical Structure and Style; this section shows what the guidelines change in FieldNotes' own API.
Clarity at the point of use
Names are judged where they are called, not where they are declared. Omit words that repeat type information, and keep the words a reader needs:
// Before: redundant words, unclear argument.
store.deleteNote(note: note)
store.notes("birds")
// After: reads as a phrase at the call site.
store.delete(note)
store.notes(taggedWith: "birds")
extension NoteStore {
public func notes(taggedWith tag: String) -> [Note] {
notes.filter { $0.tags.contains(tag) }
}
}
Naming by role, not by type
A parameter or property is named for what it means, not for its type:
// Before
func move(_ note: Note, _ notebook: Notebook)
var string: String // on NoteDraft
// After
func move(_ note: Note, to destination: Notebook)
var title: String
Naming for side effects: sort / sorted
Mutating methods read as imperative verbs, and their non-mutating counterparts read as nouns or use the "-ed" /
"-ing" suffix, exactly like the standard library’s sort() and sorted():
extension Note {
/// Adds `tag` to this note in place.
public mutating func addTag(_ tag: String) {
if !tags.contains(tag) { tags.append(tag) }
}
/// Returns a copy of this note with `tag` added.
public func addingTag(_ tag: String) -> Note {
var copy = self
copy.addTag(tag)
return copy
}
}
var heron = Note(title: "Heron")
heron.addTag("birds") // mutates
let tagged = heron.addingTag("lake") // returns a new value
let newestFirst = store.notes.sorted { $0.createdAt > $1.createdAt }
Argument labels
Label the first argument when it forms a prepositional or grammatical phrase, and omit it when the method name already describes the argument. Initializers that do a value-preserving conversion omit it too:
store.add(note) // name already says what the argument is
store.move(note, to: archive) // preposition belongs to the label
store.notes(taggedWith: "birds") // phrase continues into the label
let notebook = Notebook(name: "Field trip 2026") // initializer labels name each field
Documentation comments
Every public declaration in FieldNotesKit carries a /// comment. Xcode shows it in Quick Help, and DocC
builds it into documentation. Start with a one-sentence summary, then add - Parameter, - Returns and
- Throws where they apply:
extension NoteStore {
/// Moves a note into a different notebook.
///
/// The note keeps its identifier, tags and pin state; only its notebook changes.
///
/// - Parameters:
/// - note: The note to move. Notes the store doesn't contain are ignored.
/// - destination: The notebook that receives the note.
public func move(_ note: Note, to destination: Notebook) {
guard let index = notes.firstIndex(where: { $0.id == note.id }) else { return }
notes[index].notebookID = destination.id
}
}
Learning from Apple’s Sample Apps
Apple publishes complete sample projects that use the architecture on this page. They are the best way to see the pieces working together at full size:
| Sample | What to study in it |
|---|---|
Backyard Birds: Building an app with SwiftData and widgets |
A model-driven app built on persistent models, with the data shared between the app and interactive widgets through an App Group, and a StoreKit in-app purchase flow. It is the closest analogue to FieldNotes once parts 2 and 3 add persistence and widgets. |
Food Truck: Building a SwiftUI multiplatform app |
A single app target for iPhone, iPad and Mac with one observable model driving a |
Landmarks: Building an app with Liquid Glass |
The current version of Apple’s classic tutorial app: an |
Download a sample from its documentation page, open the .xcodeproj, and trace the path from the App struct
to the first environment injection. The same few types appear in every one.
References
-
Apple Developer Documentation — Managing model data in your app
-
Apple Developer Documentation — Managing user interface state
-
Apple Developer Documentation — Organizing your code with local packages
-
Apple Developer Documentation — Backyard Birds: Building an app with SwiftData and widgets
-
Apple Developer Documentation — Food Truck: Building a SwiftUI multiplatform app
-
Apple Developer Documentation — Landmarks: Building an app with Liquid Glass