---
name: software-ios-native
description: "Guides native iOS with Swift, SwiftUI, UIKit interop, concurrency, and persistence. Use when building or reviewing iPhone/iPad apps after establishing runtime truth."
compatibility: Portable core. Works on Claude Code and Codex.
version: "1.1"
last_validated: 2026-07-11
---

# Native iOS Development

Use this skill for native iOS work only. It is the default shared-skill entrypoint for SwiftUI-first iOS 17+ apps, bounded rewrites from older codebases, and agent-assisted workflows in Xcode, Codex, and Claude Code.

## Quick Reference

| Task | Default Picks | Notes |
|------|---------------|-------|
| **State & UI** | | |
| New UI screens | SwiftUI | UIKit interop only where Apple APIs require it |
| Observable state (iOS 17+) | `@Observable` + main actor isolation | Replaces ObservableObject/Published |
| Async work | `async`/`await`, structured concurrency | Detached tasks only when intentionally breaking inheritance |
| Unit/integration tests | Swift Testing | Preferred over XCTest for new tests |
| UI automation tests | XCTest / XCUITest | Keep using for UI and performance tests |
| **State machine discipline** | | |
| Submit guard | `guard state == .idle else { return }` | Prevents double-tap duplicate submissions in `@Observable` stores |
| Auto-reset transitions | `Task { try await Task.sleep(for: .milliseconds(500)); state = .idle }` | Composer/input ready for next action without manual UI reset |
| Minimal state enums | Remove states that can't happen anymore | Dead enum cases produce dead error handling and mislead future readers |
| **Networking & resilience** | | |
| Network reachability | `@Observable` singleton + `NWPathMonitor` | Publish `isConnected`; disable submit buttons when offline; start monitor in screen `.onAppear` |
| **Agent tooling & build** | | |
| Agent tooling (in Xcode) | Xcode native assistant | Current stable is Xcode 26.6 (Swift 6.3); Xcode 27 beta (Swift 6.4, on-device AI code completion) shipped from WWDC26 but is not yet GA — do not build release submissions against a beta SDK |
| Agent tooling (outside Xcode) | XcodeBuildMCP if callable | Otherwise fall back immediately to Apple CLI |
| CLI fallback | `xcodebuild`, `simctl`, `xcresulttool` | Default path when MCP is unavailable or blocked |
| Build / install / stale-app failures | `software-ios-runtime-debugging` | Use before UI or feature diagnosis |
| XcodeGen projects | `scripts/generate-xcodeproj.sh` | Must regenerate after adding new Swift files |
| **Local-dev launcher pair** | `scripts/run-local-ios-dev.sh` + `scripts/stop-local-ios-dev.sh` | Two defensive guards (grep env + generated plist for `localhost:`); persist simulator UDID; never `pkill -f Simulator`. See [quick-reference-extended.md#agent-tooling--build](references/quick-reference-extended.md#agent-tooling--build) |
| `.pbxproj`-managed projects | Add new files to target membership | Do not assume on-disk Swift files are auto-discovered |
| **Generated files / CI landmines** | Commit generated outputs or regenerate in CI hook | `git add -f` for gitignored-folder tracked files; Xcode Cloud `ci_post_clone.sh` for env shims. See [quick-reference-extended.md](references/quick-reference-extended.md#agent-tooling--build) |
| **TARGETED_DEVICE_FAMILY → "1"** | Requires fresh archive + ASC build attachment | Changing `project.yml` alone is insufficient; value is baked into binary. |
| **Canvas & visualization** | | |
| Canvas data views | Start `animatedProgress` at 1 | Data loads after `.onAppear`; starting at 0 leaves Canvas empty |
| Canvas gestures | `DragGesture` + `SpatialTapGesture` overlay | Compute hit targets from coordinates, not invisible tap areas |
| Type checker crashes | Split complex views into helper functions | `.overlay { if }` and tuple `ForEach` are common triggers |
| **StoreKit & billing** | | |
| StoreKit 2 subscriptions | `@Observable` StoreKitManager + TransactionSyncService | `transaction.finish()` only after backend sync confirms |
| Paywall presentation | `.sheet(isPresented:)` from any locked screen | Don't navigate to Settings; present modal directly |
| Product pricing display | `product.displayPrice` from StoreKit | Never hardcode prices; Apple handles locale formatting |
| Promotional offers (rewards) | Server-signed `.promotionalOffer()` in `Product.purchase(options:)` | Bridges Stripe credit gaps for Apple-billed users; user must redeem |
| **Paid Apps Agreement (#1 invisible blocker)** | Verify Active at appstoreconnect.apple.com/business | Silent empty `Product.products(for:)` results; check before subscription-level diagnosis. Full decision table + timeline in [app-store-connect-checklist.md Phase 5](../software-mobile/references/app-store-connect-checklist.md). See [quick-reference-extended.md](references/quick-reference-extended.md#storekit--billing) |
| **ASC `Missing Metadata`** | Fill all required fields: prices, localization, review screenshot | Products exist but fields incomplete. Workflow in [app-store-connect-checklist.md](../software-mobile/references/app-store-connect-checklist.md). See [quick-reference-extended.md](references/quick-reference-extended.md#storekit--billing) |
| **Review screenshot** | sRGB 8-bit RGB PNG 72 DPI, 1284 × 2778 | ASC rejects Display P3 / 16-bit device screenshots; use Pillow profile conversion. See [quick-reference-extended.md](references/quick-reference-extended.md#storekit--billing) |
| **Accounting currency (backend-denominated)** | `NumberFormatter` + explicit `currencyCode` + `locale = .current` | Never `String(format: "£%.2f", …)`; details in [quick-reference-extended.md](references/quick-reference-extended.md#storekit--billing) |
| Year/ID display | `Text(verbatim:)` | Suppress locale number formatting (2026 not 2,026) |
| **Immersive screens & sheets** | | |
| Immersive viz screens | `ZStack { bg; scene; controls; .sheet(persistent) }` | No scroll, no card wrappers, sheet peek + bottom padding |
| Persistent sheet | `.presentationBackgroundInteraction(.enabled(upThrough: .medium))` | Allows gestures on viz while sheet is visible |
| Viz state: inspect (chart) | Parent `@Binding` for zoom/drag | Controls strip reads/writes same state |
| Viz state: orbit (3D) | Parent `@State` + callback | SceneKit drives camera, reports back |
| Viz state: navigate (map) | Internal `@State` | Zero-latency gesture response for spatial pan/zoom |
| Parent-initiated reset | Reset token (`Int` incremented by parent) | View `.onChange(of: token)` resets internal state |
| Viz overlay controls | `Picker(.segmented)` + `Menu` | Native controls, not custom material-backed buttons |
| Dense diagram controls | Zoom in/out/reset + semantic filters | When gates, labels, or markers overlap, expose inspection controls before redrawing the whole chart |
| Diagram detail placement | Below-chart summary or persistent sheet | Do not cover the diagram with popups or bottom overlays that block inspection |
| Canvas animation (glow/pulse) | `TimelineView(.animation)` wrapping Canvas | Gate behind condition to avoid 30fps waste |
| Square Canvas sizing | `.aspectRatio(1, contentMode: .fit)` | Prevents dead space in taller-than-wide frames |
| Viz export/share | `ImageRenderer` + `proposedSize` | Static export view, no gestures or parallax |
| Immersive shared state | `@Observable` class via `@State` + `@Bindable` | Avoids 15+ `@Binding` prop-drilling |
| Year/ID in `navigationTitle` | String concat: `name + " " + String(year)` | No `verbatim:` overload — interpolation resolves to `LocalizedStringKey` |
| Multiple sheets | Multiple `.sheet` modifiers on one view | iOS 16.4+; each gated by its own `Optional` property |
| Grid cell width | `.frame(maxWidth: .infinity)` inside `LazyVGrid` cells | Content doesn't stretch automatically; without this, columns are unequal |
| Help/detail row width | `.frame(maxWidth: .infinity, alignment: .leading)` | Leading `VStack` rows shrink to intrinsic width unless explicitly stretched, making peer containers uneven |
| Grid cell clip shape | `RoundedRectangle` not `Capsule` | Capsule pinches at wide aspect ratios in stretched grid cells |
| Deleting `.pbxproj` files | Remove `PBXBuildFile`, `PBXFileReference`, group child, and sources entry | Missing any one leaves stale references or build warnings |
| Sheet swapping | Set `item` to `nil`, delay ~350ms, set new value | `.sheet(item:)` can't swap in one frame; `onDismiss` + state = loop |
| l10n in plain enums | Call l10n in View body, not enum methods | `@MainActor` l10n store can't be called from nonisolated enum funcs |
| l10n key coverage | Verify all `l10n.text()` keys exist in locale JSONs | Fallback strings mask missing keys in the default language; switch language to confirm |
| l10n value coverage | Verify new non-English values are not English fallbacks | Key parity alone is insufficient; generated catalogs can contain English defaults in every locale |
| l10n large-file edits | Use `json.load` → modify → `json.dump` for locale JSONs | The Edit tool silently fails on files >500KB; always verify writes programmatically |
| Generated l10n catalogs | Fix upstream source-of-truth, then regenerate | Editing only the generated iOS copy is a stopgap — next regeneration overwrites it |
| `LocalizationStore.text` crash | Patch source-of-truth, regenerate, test | Stack trace in `resolvedTemplate` = shipped catalog missing key; Swift fallback alone doesn't survive next generation |
| **Backend locale propagation** | `?locale=` query + `Accept-Language` header on every request | Priority: `?locale=` > `Accept-Language` > stored profile. Call `propagateLocaleToAPIClient()` on every picker change. See [quick-reference-extended.md](references/quick-reference-extended.md#l10n--backend-locale) |
| **Locale-aware time formatting** | `DateFormatter.dateFormat(fromTemplate: "jm", options: 0, locale: locale)` | "jm" skeleton respects 12h/24h user override; cache instances in `NSCache`. See [quick-reference-extended.md](references/quick-reference-extended.md#l10n--backend-locale) |
| **Backend prose vs structural data** | Structural data: client-side enum helpers; prose: server-side translation | Chart positions/names need no backend translation; only generated prose does. See [quick-reference-extended.md](references/quick-reference-extended.md#l10n--backend-locale) |
| **Interpolated prose template split** | Translate `prefix`/`suffix` templates; inject runtime values verbatim | Never cache strings with embedded runtime values (explodes cache key space). See [quick-reference-extended.md](references/quick-reference-extended.md#l10n--backend-locale) |
| **Auth & push** | | |
| Sign in with Apple | `ASAuthorizationController` + `CheckedContinuation` | Required by Guideline 4.8 if any 3rd-party social login is offered |
| OTP code input | Hidden `TextField` + `.textContentType(.oneTimeCode)` | Better than magic links for native; iOS auto-fills from notifications |
| Non-`@MainActor` delegates | `nonisolated` + `MainActor.assumeIsolated` | For `ASAuthorizationControllerDelegate`, `MKLocalSearchCompleterDelegate`, etc. |
| Push notification categories | Register `UNNotificationCategory` in `didFinishLaunchingWithOptions` | Must be set before any notification arrives; match `aps.category` from backend |
| Badge count (iOS 16+) | `try? await UNUserNotificationCenter.current().setBadgeCount(0)` | `applicationIconBadgeNumber` is deprecated; `setBadgeCount` is `async throws` |
| Push delegate isolation | `@unchecked Sendable` + `@MainActor` async UN delegate | `nonisolated async` + nested `await MainActor.run` crashes with `_performBlockAfterCATransactionCommitSynchronizes:`. See [swiftui-observation-concurrency.md](references/swiftui-observation-concurrency.md#nonisolated-async-delegate-methods--nested-mainactorrun) and [quick-reference-extended.md](references/quick-reference-extended.md#auth--push--detailed-rows) |
| Bare `Task { }` isolation | `Task { @MainActor [weak self] in ... }` from `@MainActor` classes | Bare `Task {}` runs on global executor; does NOT inherit `@MainActor`. See [swiftui-observation-concurrency.md](references/swiftui-observation-concurrency.md#bare-task---does-not-inherit-mainactor) |
| `actor` vs `@MainActor final class` | Prefer `@MainActor final class` when all consumers are `@MainActor` | Round-trip `@MainActor → actor → @MainActor` causes post-await tail on wrong executor. See [swiftui-observation-concurrency.md](references/swiftui-observation-concurrency.md#actor--mainactor-final-class-refactoring-guidance) |
| `@Sendable async` closure awaited from `@MainActor` | Retype as `@MainActor async` closure | Does NOT reliably resume on main; common in SDK `RequestExecutor` typealiases. See [swiftui-observation-concurrency.md](references/swiftui-observation-concurrency.md#sendable-async-closure-isolation-footgun) |
| `SCNView` in `UIViewRepresentable` | Build scene once in `makeUIView`, `updateUIView` is a no-op | Reassigning `scene` in `updateUIView` crashes via CATransaction off-main. See [swiftui-observation-concurrency.md](references/swiftui-observation-concurrency.md#scnview-reassignment-in-updateuiview-anti-pattern) |
| Push action routing | Check `response.actionIdentifier` in `didReceive` | `UNNotificationDefaultActionIdentifier` = tap; custom IDs = action buttons; dismiss = no route |
| Push-open ownership, preferences, entitlements, APNs proof, archive gate, backend routing, QA loop | See full rows in [quick-reference-extended.md](references/quick-reference-extended.md#auth--push--detailed-rows) | Archive gate: `codesign -d --entitlements` must print `production`; backend: per-device `push_environment` column authoritative |
| Swift Concurrency crash triage | Symptom-first triage runbook | Private SwiftUI symbol crash → start at [swift-concurrency-crash-triage.md](../software-ios-runtime-debugging/references/swift-concurrency-crash-triage.md); ladder: console → MTC → TSan → lldb `bt` |
| **SwiftUI API modernization** | | |
| Deprecated API review | [references/swiftui-deprecated-api.md](references/swiftui-deprecated-api.md) | Systematic deprecated→modern mapping |
| SwiftUI performance audit | [references/swiftui-performance.md](references/swiftui-performance.md) | View splitting, lazy stacks, modifier efficiency |
| Modern Swift idioms | [references/modern-swift-patterns.md](references/modern-swift-patterns.md) | Foundation modernization, date/string/collection patterns |
| **Concurrency (constructive)** | | |
| Writing correct concurrency | [references/swift-concurrency-patterns.md](references/swift-concurrency-patterns.md) | Structured concurrency, async streams, bridging, migration |
| Concurrency compiler errors | [references/swift-concurrency-diagnostics.md](references/swift-concurrency-diagnostics.md) | Error→fix mapping for Swift 6 diagnostics |
| **Persistence** | | |
| SwiftData modeling | [references/swiftdata-core.md](references/swiftdata-core.md) | Core rules, predicates, CloudKit, indexing, class inheritance |
| Core Data persistence | [references/core-data-persistence.md](references/core-data-persistence.md) | Stack setup, contexts, object IDs, batch ops, migrations, CloudKit |
| Reusable app skeleton | [references/native-ios-app-foundation-skeleton.md](references/native-ios-app-foundation-skeleton.md) | SwiftUI shell, Observation state, persistence, CloudKit, App Intents, local AI hooks, release gates |
| iCloud database app | [references/icloud-cloudkit-app-skeleton.md](references/icloud-cloudkit-app-skeleton.md) | SwiftData/Core Data/CloudKit choice, private/public/shared scopes, no-server limits |
| **Stacks & monetization** | | |
| Pick a starter stack to monetize/engage | [references/starter-stacks-and-monetization.md](references/starter-stacks-and-monetization.md) | CloudKit→Cloudflare→RevenueCat→Supabase graduation ladder; on-device AI as free tier; webhook idempotency traps |
| Run iOS dev as a conveyor / app factory | [references/ios-app-conveyor.md](references/ios-app-conveyor.md) | 4 pillars: default stack per class, shared SPM, Fastlane+Match CI, agent build loop; 2026 stack survey |
| **Build performance** | | |
| Xcode build optimization | [references/xcode-build-optimization.md](references/xcode-build-optimization.md) | Benchmarking, diagnostic flags, SPM analysis, common wins |
| **Swift 6.2+ / Xcode 26+ additions** | | |
| Default actor isolation (new projects) | `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` in Xcode 26+ | App targets benefit most; library targets stay `nonisolated`. Flip flag, triage leaf types, mark non-UI `nonisolated`/`@concurrent`, remove defensive `MainActor.run` wraps. Do NOT bulk-silence with `@preconcurrency` |
| `withAnimation` in `@MainActor` | Known iOS 26 regression | Hoist `withAnimation` outside `@MainActor` body or wrap in `Task { @MainActor in withAnimation(…) { … } }`; verify on iOS 26.0 / 26.2 / 26.4 |
| `SubscriptionStatus.all` stale after subscription change | Xcode 26 StoreKit 2 bug | Query `Product.SubscriptionInfo.Status` directly after `Transaction.updates` tick; don't trust cached `all` array |
| `AnyView` avoidance | Type erasure defeats diffing | Use `@ViewBuilder`, `some View`, or `Group + if/switch` instead |
| `@ObservedObject` in new code | Legacy pre-iOS-17 pattern | Replace with `@Observable` + `@Bindable`; `@StateObject` → `@State`; `@Published` unnecessary on `@Observable` |
| `NavigationView` deprecation | Replaced | `NavigationStack` for push/pop; `NavigationSplitView` for multi-column |
| `.id(UUID())` force refresh | Anti-pattern | Causes full re-init + retained subscriptions; drive refreshes from state |
| Combine subscription lifecycle | Memory leak #1 | Every `sink`/`assign` into `.store(in: &cancellables)` or cancel in `onDisappear`/`deinit` |
| Coordinator-pattern navigation | `NavigationStack` + enum routes | `@Observable AppCoordinator` with `path`; inject via `@Entry`; one coordinator per module |

## When to Use This Skill

SwiftUI-first iOS 17+ screens, app skeletons, UIKit bounded rewrites, agent-assisted Xcode/Codex/Claude Code workflows, Swift Concurrency, SwiftData/Core Data persistence, Xcode build optimization, privacy manifests, release gates, and native iOS code review.

## Defaults

- UI: SwiftUI-first; UIKit interop only where Apple APIs require it.
- State: `@Observable` + main actor isolation (iOS 17+).
- New projects: `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` (Xcode 26+); `nonisolated`/`@concurrent` only when breaking out.
- Async: structured concurrency + `async`/`await`; detached tasks only when intentionally breaking inheritance.
- Tests: Swift Testing for unit/integration; XCTest/XCUITest for UI automation.
- Release gates: privacy manifests, required-reason APIs, SDK compliance, accessibility, real-device verification — non-optional. Verify minimum Xcode version at developer.apple.com/news/releases each release cycle.

## Version Currency (check every session)

- **Current stable (verify at each session):** iOS/iPadOS 26.x, Xcode 26.6, Swift 6.3. App Store Connect has rejected uploads not built with the iOS 26 SDK since 2026-04-28 — this gate does not change your deployment target, only the build SDK.
- **Announced, not shipped:** WWDC26 (June 2026) previewed iOS/iPadOS/macOS 27 and Xcode 27 (Swift 6.4, on-device AI code completion, agent skills bundled with Xcode). As of this session Xcode 27 is beta-only. Do not recommend Xcode 27/iOS 27 APIs for anything shipping to the App Store; treat every "iOS 27" claim as provisional until Apple's GA release notes confirm it, and re-verify the current stable/beta split before quoting a specific point release — these move every few weeks.
- Point releases (26.2 vs 26.4 vs 26.6) drift fast; treat any specific point-release number in this skill as the value observed at last validation, not a permanent fact.

## Expert Judgment Calls

- **SwiftUI vs UIKit:** default to SwiftUI. Reach for UIKit interop only for a named capabilit