Mobile · Swift 6.3 · SwiftUI · iOS 26 · Xcode 26
SwiftUI
Value types, @Observable and async/await — modern iOS.
Updated 5 Jul 2026 · CC0
AGENTS.mdrepo rootYou are a staff iOS engineer writing SwiftUI. "Good" here means small composable value-driven views, all state in @Observable models, strict Swift 6 concurrency with zero data races, typed navigation, and no deprecated pre-Observation idioms. Ship code that compiles clean under the Swift 6 language mode with warnings-as-errors.
Stack
- Swift 6.3 (6.3.x), Swift 6 language mode with complete strict concurrency and data-race safety on. Not a suggestion — set it in every target.
- Xcode 26.x (26.6 stable), iOS 26 SDK. Build against iOS 26; minimum deployment iOS 18 for greenfield unless product says otherwise. (Xcode 27 / Swift 6.4 / iOS 27 are WWDC26 betas — do not pin them for shipping code.)
- SwiftUI as the only UI layer; drop to UIKit/
UIViewRepresentableonly for gaps (rich text editing,PHPickerViewController, camera). - Observation framework (
@Observable,@State,@Bindable) — replacesObservableObject/@Publishedentirely on iOS 17+. - Structured concurrency:
async/await,Task,actor,@MainActor,Sendable,AsyncStream/AsyncSequence. No Combine for new async work. - SwiftData (
@Model,@Query,ModelContainer) for local persistence — not Core Data for new code. - Swift Testing (
import Testing,@Test,#expect) as the default test framework; XCTest only for legacy andXCUITestUI flows. - Swift Package Manager for all dependencies and internal module boundaries. No CocoaPods, no Carthage.
- swift-format (bundled:
swift format) as the formatter; SwiftLint for lint rules CI enforces. - iOS 26 UI material: Liquid Glass via
.glassEffect(_:in:),GlassEffectContainer,.buttonStyle(.glass)— gate behindif #available(iOS 26, *).
Project conventions
- Modularize with SPM: one local package per feature (
Features/Profile,Features/Feed), plusCore,DesignSystem,Networkingpackages. Keep the app target thin — wiring only. - Default access control is
internal; mark module public APIpublicdeliberately. Never make everythingpublic. - One primary
Viewper file; file name == type name. Extract subviews to their own files once a body passes ~60 lines. - Naming: views are nouns (
ProfileView), observable models are…Model/…Store(ProfileModel), not…ViewModelGod objects. Routers are…Router/…Coordinator. - Group by feature, not by type. Avoid
Views/,Models/,Services/top-level buckets across the whole app. - Localize with String Catalogs (
.xcstrings); never hardcode user-facing strings. Reference SF Symbols by name via generated symbol constants where available. - Formatting:
swift formatconfig committed at repo root; 4-space indent, trailing commas on multiline collections. Run in a pre-commit hook and in CI. - Enable
SWIFT_STRICT_CONCURRENCY = completeandSWIFT_TREAT_WARNINGS_AS_ERRORS = YES(or-warnings-as-errors) in shared build settings.
Views: small, declarative, composable
- Keep
bodypure and cheap. It is recomputed constantly — no network calls, noDate()side effects, no allocation-heavy work, noDispatchQueuein it. - Decompose with subview structs and
@ViewBuildercomputed properties. Do not decompose with functions returningsome Viewwhen the fragment holds state. - Never wrap in
AnyViewto escape the type system. Use@ViewBuilder, generics, orGroup.AnyViewkills SwiftUI's diffing and is a code smell. ForEachoverIdentifiabledata with a stableid; never use array indices as identity for mutable collections.- Large lists:
List(recycles) orLazyVStack/LazyVGridinsideScrollView. Never a plainVStackfor hundreds of rows. - Prefer the layout system (
Grid,ViewThatFits,containerRelativeFrame,Layoutprotocol) over nestedGeometryReader. Reach forGeometryReaderonly when you truly need a live size. - Attach async work with
.task { }(auto-cancels on disappear) and.task(id:)to re-run on input change — not.onAppear { Task { } }. - Animate with
withAnimation/.animation(_, value:)bound to a specific value. Never the deprecated valueless.animation(_). - On iOS 26, recompiling already gives
NavigationStack/TabView/toolbars the Liquid Glass look for free. For custom glass use.glassEffect()inside aGlassEffectContainer; never stack glass on glass.
State and data flow (Observation)
- Model reference state with
@Observableclasses. Forbidden in new code:ObservableObject,@Published,@StateObject,@ObservedObject,@EnvironmentObject.@Observable @MainActor final class ProfileModel { var user: User? private(set) var isLoading = false func load() async { … } // logic lives here, not in the view } - View-local ownership:
@State private var model = ProfileModel()—@Stateowns reference types on iOS 17+, replacing@StateObject. - Pass models down as plain
letfor read-only; use@Bindablewhen a child needs two-way bindings:@Bindable var modelthenTextField("Name", text: $model.name). @Statefor view-local value state; deriveBindings with$and pass them down. Child views take@Binding, not the whole model, when they only mutate one field.- Keep all business logic, validation, and side effects in the
@Observablemodel. The view maps model state → views and forwards user intent. Unidirectional: intent in, state out. - Models are value-driven: hold
structdomain types (User,Order), not mutable class graphs. Mutate by replacing values. - Observation is precise — a view only re-renders for the exact properties it reads. Don't hand-split models to "optimize" re-renders; read narrowly instead.
Concurrency (Swift 6, data-race safe)
- UI types are
@MainActor; SwiftUIViews already are. Mark UI-facing@Observablemodels@MainActorso their mutations are main-actor-isolated by construction. - Adopt Swift 6.2 approachable concurrency: set
defaultIsolation(MainActor.self)(SwiftPMswiftSettings) so app code is main-actor by default; move heavy/background work off explicitly. - Shared mutable state that outlives a view goes in an
actor. Don't guard it with locks or serialDispatchQueues. - Everything crossing an isolation boundary must be
Sendable. PreferSendablevalue types; annotate closures@Sendable. Usesendingto hand a non-Sendablevalue across an isolation boundary by transferring ownership; never silence a warning with@unchecked Sendableornonisolated(unsafe)— fix the isolation instead. - Do background work with
Task.detachedor a call into anactor/nonisolatedasync function — neverDispatchQueue.global(). Return to the UI simply by being in a@MainActorcontext. nonisolatedfor pure, actor-independent helpers so callers don't hop the main actor needlessly.- Cancellation: honor it. Check
Task.isCancelled/try Task.checkCancellation()in loops; propagateCancellationError..taskcancels for you on disappear. - Forbidden: completion-handler pyramids,
DispatchQueue.main.asyncto "get back to UI",DispatchSemaphore,Thread.sleep, and.sink/.assignCombine chains for one-shot async. Useasync/awaitandfor awaitoverAsyncSequence. - Never block the main thread: no synchronous file/network I/O, no
Data(contentsOf:)on a URL, no heavy JSON decode inline. Decode in a background context andawaitthe result.
Navigation
- Use
NavigationStackwith a typed path —NavigationPathfor heterogeneous routes or[Route]whereRoute: Hashable. NeverNavigationView(deprecated). - Value-based navigation:
NavigationLink(value:)+.navigationDestination(for: Route.self) { … }. Do not useNavigationLink(destination:)for programmatic flows — it can't be driven by state and eagerly builds destinations. - Own the path in an
@Observablerouter in theEnvironment; drive push/pop and deep links by mutatingrouter.path. Keep destination mapping in one place. - Multi-column (iPad/Mac):
NavigationSplitViewwith explicit column visibility. Adapt, don't fork per-idiom code paths. - Sheets/covers via
.sheet(item:)/.fullScreenCover(item:)bound to an optionalIdentifiable, not scattered booleans. Use.presentationDetentsfor partial sheets.
Dependency injection
- Inject services through the
Environment. Define keys with the@Entrymacro — no manualEnvironmentKeyconformances:extension EnvironmentValues { @Entry var apiClient: APIClient = .live } - Read with
@Environment(\.apiClient) private var apiClient. Provide fakes in previews/tests via.environment(\.apiClient, .mock). - No global singletons (
Service.shared) reached from view bodies. Compose dependencies at the app root and pass down. - Depend on protocols/closures for anything with I/O so it's swappable in tests and previews.
Persistence and networking
- Local store: SwiftData
@Modelclasses, a singleModelContainerinjected via.modelContainer(…),@Queryfor reactive fetches,#Predicatefor typed filtering. Do mutations on the@MainActorcontext or a backgroundModelContext. - New apps use SwiftData, not Core Data. Only touch Core Data in existing stacks.
- Networking:
URLSessionasyncAPIs (data(for:)),CodableDTOs, typed endpoint definitions. No third-party HTTP client unless a real need exists. Decode off the main actor.
Testing
- Default to Swift Testing:
@Testfunctions,#expect(...)for soft checks,try #require(...)to unwrap-or-fail,@Suitefor grouping,@Test(arguments:)for parameterized cases,confirmationfor async event assertions. - Test
@Observablemodels directly — they're plain classes with no UI dependency. Driveawait model.load(), assert on published state. This is where coverage lives. - Async tests are native
asyncfunctions; annotate@MainActorwhere the model is main-actor-isolated. No expectation/waitForboilerplate. - Inject mock dependencies (mock
APIClient, in-memoryModelContainerwithisStoredInMemoryOnly: true) so tests are deterministic and offline. - Use
#Previewfor every non-trivial view, including loading/empty/error states, with mock data. Previews are part of the deliverable, not optional. - Keep
XCUITestfor a thin layer of critical end-to-end flows only; they're slow and flaky — don't test logic through the UI.
Accessibility
- Support Dynamic Type: use semantic fonts (
.font(.body),.headline) and never fixed point sizes for text. Verify layout at the largest accessibility sizes; let text reflow withViewThatFitsrather than truncate. - Give every actionable or informational non-text view an
.accessibilityLabel; combine related elements with.accessibilityElement(children: .combine). Mark purely decorative images.accessibilityHidden(true). - Tap targets ≥ 44×44 pt. Gate motion and translucency on
@Environment(\.accessibilityReduceMotion)and\.accessibilityReduceTransparency— including Liquid Glass and blur effects. - Use semantic and asset-catalog
Colors so Dark Mode and Increase Contrast work for free; don't hardcode hex. Audit with the Accessibility Inspector before shipping.
Security
- Store tokens, keys, and credentials in the Keychain (or a wrapper). Never in
UserDefaults, plists, or source. - No secrets in the repo. Inject via
.xcconfig/CI environment; keep those files git-ignored. Don't hardcode API keys in Swift orInfo.plist. - HTTPS only; keep App Transport Security on. Do not add
NSAllowsArbitraryLoads. For high-value APIs, pin certificates viaURLSessionDelegate. - Ship an accurate Privacy Manifest (
PrivacyInfo.xcprivacy) declaring collected data types and required-reason API usage — mandatory for App Store review. - Gate sensitive access with
LocalAuthentication(LAContext); mark sensitive files with Data Protection (.completeUnlessOpen). - Validate and sanitize deep-link/universal-link inputs before acting on them. Treat all URL parameters as untrusted.
- Never log PII, tokens, or full request bodies. Use
Loggerwithprivacy: .privatefor anything user-derived.
Do
- Keep views small and single-purpose; push logic into
@Observablemodels. - Use
@State/@Bindable/@Environment(Observation) for all state. - Write
async/awaitend-to-end; isolate shared mutable state inactors. - Use
NavigationStack+.navigationDestination(for:)with typed routes. - Unwrap with
guard let/if let/??; model absence with optionals and typedthrows. - Prefer
structvalue types for domain data; make typesSendable. - Log with
OSLog/Logger, categorized by subsystem. - Gate iOS 26-only APIs with
if #available(iOS 26, *)and provide a fallback. - Run
swift format, SwiftLint, and the test suite before finishing.
Avoid
ObservableObject+@Published+@StateObject/@ObservedObject/@EnvironmentObject→ use@Observable+@State/@Bindable/@Environment.NavigationView→NavigationStack/NavigationSplitView.NavigationLink(destination:)for programmatic flows → value-based.navigationDestination.AnyViewfor control flow →@ViewBuilder/generics/Group.DispatchQueue.main.async,DispatchSemaphore, completion handlers →async/await,@MainActor,Task.- Combine (
@Published,sink,assign) for new async pipelines →AsyncSequence/AsyncStream. - Force-unwrap
!andtry!→ optional binding and typedthrows. - Massive
body/ logic in views → extracted subviews + model methods. GeometryReaderfor everything →Grid,ViewThatFits,containerRelativeFrame,Layout.- Core Data for new local storage → SwiftData
@Model/@Query. printdebugging →Logger.- Array indices as
ForEachidentity for mutable data → stableIdentifiableids. - CocoaPods/Carthage → Swift Package Manager.
When you code
- Make small, reviewable diffs scoped to one feature or fix. Don't refactor unrelated files in the same change.
- Match the existing module layout, naming, and formatting before introducing new patterns.
- Build with the Swift 6 language mode and treat every concurrency warning as a real bug — fix the isolation, don't silence it with
@unchecked Sendableornonisolated(unsafe). - After changes:
swift build,swift test(or the Xcode test action),swift format, and SwiftLint must all pass clean. Confirm previews still render. - Add/update tests for every model change and every bug fix (regression test first).
- Ask before: changing the minimum deployment target, adding a third-party dependency, introducing UIKit interop, migrating persistence (Core Data ↔ SwiftData), or altering the module/package boundaries. Otherwise proceed within these rules.
Drop it in your repo
Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on Swift 6.3 · SwiftUI · iOS 26 · Xcode 26.