ios-native

v2026.09.24

iOS native platform development with SwiftUI 6.x: app architecture (App protocol, Scene, WindowGroup), navigation (NavigationStack, NavigationSplitView), state (@State, @Binding, @Observable, @Environment), Keychain Services with biometric protection, Secure Enclave (P-256 hardware-isolated keys), App Lifecycle (BGTaskScheduler, Background Modes), Universal Links, App Groups, Share Extensions, Privacy Manifest, StoreKit 2. USE WHEN: user mentions "SwiftUI", "@Observable", "NavigationStack", "@AppStorage", "Keychain Services", "Secure Enclave", "BGTaskScheduler", "Universal Links", "App Groups", "Share Extension", "Privacy Manifest", "StoreKit", "iOS native", "@SceneStorage", "@FocusState" DO NOT USE FOR: Compose Multiplatform on iOS - use `frontend-frameworks/compose-multiplatform` DO NOT USE FOR: Swift language patterns - use `languages/swift` DO NOT USE FOR: Rust ↔ Swift bindings - use `languages/uniffi` DO NOT USE FOR: Cross-platform iOS+Android - use `mobile/kotlin-multiplatform`

GitHub
Install command
npx skhub add claude-dev-suite/ios-native
Markdown
SKILL.md

iOS Native Development (SwiftUI + Platform APIs)

References: swiftui-architecture.md for App lifecycle, Scene/WindowGroup, navigation (NavigationStack with type-safe paths, NavigationSplitView), state management with @Observable (Swift 5.9+ replacement for ObservableObject), focus, sheets/alerts. secure-storage.md for Keychain Services deep dive (access groups, biometric SAC, iCloud sync, key migration), Secure Enclave key generation, attestation. system-integration.md for Background Tasks (BGTaskScheduler), Universal Links (associated domains), App Groups, Share Extensions, Privacy Manifest, StoreKit 2.

Deep Knowledge: Use mcp__documentation__fetch_docs with technology: ios or swiftui.

App Entry Point (SwiftUI App Protocol)

import SwiftUI

@main
struct BHODLApp: App {
    @State private var sessionStore = SessionStore()                // @Observable model

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(sessionStore)
        }
    }
}

@Observable                                                         // Swift 5.9+
class SessionStore {
    var isAuthenticated = false
    var userId: String?
}

@Observable (from Observation framework, Swift 5.9+) replaces ObservableObject + @Published. Cleaner, more performant — only re-renders views that read changed properties.

Scene Types

SceneUse case
WindowGroupStandard app window (multi-window on iPad/Mac)
DocumentGroupDocument-based app (file editor, drawing)
SettingsmacOS Settings scene (Mac-only)
MenuBarExtraMenu bar item (macOS)
ImmersiveSpacevisionOS immersive scene
@main
struct BHODLApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        #if os(macOS)
        Settings {
            SettingsView()
        }
        #endif
    }
}

State Management

@State (Local view state)

struct CounterView: View {
    @State private var count = 0

    var body: some View {
        VStack {
            Text("Count: \(count)")
            Button("Increment") { count += 1 }
        }
    }
}

@Binding (Two-way reference to state owned elsewhere)

struct ToggleRow: View {
    @Binding var isOn: Bool
    let title: String

    var body: some View {
        Toggle(title, isOn: $isOn)
    }
}

// Usage
struct SettingsView: View {
    @State private var notifications = true

    var body: some View {
        ToggleRow(isOn: $notifications, title: "Notifications")
    }
}

@Observable (Shared model)

@Observable
class WalletStore {
    var wallets: [Wallet] = []
    var isLoading = false
    var error: String?

    func load() async {
        isLoading = true
        defer { isLoading = false }
        do {
            wallets = try await api.fetchWallets()
        } catch {
            self.error = error.localizedDescription
        }
    }
}

struct WalletList: View {
    @State private var store = WalletStore()

    var body: some View {
        List(store.wallets) { wallet in
            WalletRow(wallet: wallet)
        }
        .task { await store.load() }
    }
}

@Environment (Dependency injection via env)

// Custom env value
private struct WalletAPIKey: EnvironmentKey {
    static let defaultValue: WalletAPI = LiveWalletAPI()
}

extension EnvironmentValues {
    var walletAPI: WalletAPI {
        get { self[WalletAPIKey.self] }
        set { self[WalletAPIKey.self] = newValue }
    }
}

// Inject
ContentView()
    .environment(\.walletAPI, MockWalletAPI())

// Read
struct WalletList: View {
    @Environment(\.walletAPI) var api
    // ...
}

// For @Observable types — direct env injection (Swift 5.9+)
@main
struct BHODLApp: App {
    @State private var store = WalletStore()

    var body: some Scene {
        WindowGroup {
            ContentView().environment(store)
        }
    }
}

struct ChildView: View {
    @Environment(WalletStore.self) var store
    // ...
}

@AppStorage (UserDefaults binding)

struct SettingsView: View {
    @AppStorage("theme") var theme: String = "system"
    @AppStorage("biometric_enabled") var biometricEnabled: Bool = false

    var body: some View {
        Form {
            Picker("Theme", selection: $theme) {
                Text("Light").tag("light")
                Text("Dark").tag("dark")
                Text("System").tag("system")
            }
            Toggle("Biometric unlock", isOn: $biometricEnabled)
        }
    }
}

For App Groups (shared with extensions):

@AppStorage("theme", store: UserDefaults(suiteName: "group.com.bhodl"))
var theme: String = "system"

@SceneStorage (Per-scene state survival)

struct SearchView: View {
    @SceneStorage("search_query") var query: String = ""

    var body: some View {
        TextField("Search", text: $query)
            // Survives app suspension/scene restoration
    }
}

Navigation

NavigationStack (Type-safe paths — iOS 16+)

struct ContentView: View {
    @State private var path = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
            HomeView()
                .navigationDestination(for: Wallet.self) { wallet in
                    WalletDetailView(wallet: wallet)
                }
                .navigationDestination(for: Transaction.self) { tx in
                    TransactionDetailView(transaction: tx)
                }
        }
    }
}

// Navigate by appending to path
struct HomeView: View {
    @Binding var path: NavigationPath  // or pass from environment

    var body: some View {
        Button("Open wallet") {
            path.append(myWallet)       // any Hashable type
        }
    }
}

// Pop
path.removeLast()
path = NavigationPath()                  // pop to root

For type-safe path with explicit enum:

enum AppRoute: Hashable {
    case wallet(id: String)
    case transaction(txId: String)
    case settings
}

@State private var path: [AppRoute] = []

NavigationStack(path: $path) {
    HomeView()
        .navigationDestination(for: AppRoute.self) { route in
            switch route {
            case .wallet(let id): WalletDetailView(walletId: id)
            case .transaction(let txId): TransactionDetailView(txId: txId)
            case .settings: SettingsView()
            }
        }
}

NavigationSplitView (iPad/Mac master-detail)

struct ContentView: View {
    @State private var selectedWallet: Wallet?
    @State private var selectedTx: Transaction?

    var body: some View {
        NavigationSplitView {
            WalletList(selection: $selectedWallet)
        } content: {
            if let wallet = selectedWallet {
                TransactionList(walletId: wallet.id, selection: $selectedTx)
            } else {
                Text("Select a wallet")
            }
        } detail: {
            if let tx = selectedTx {
                TransactionDetailView(transaction: tx)
            } else {
                Text("Select a transaction")
            }
        }
    }
}

Auto-collapses to NavigationStack on iPhone.

See swiftui-architecture.md for sheets, fullScreenCover, alerts, popovers, deep linking.

Common UI Patterns

List with Sections

struct WalletListView: View {
    let wallets: [Wallet]

    var body: some View {
        List {
            Section("On-chain") {
                ForEach(wallets.filter { $0.type == .onchain }) { w in
                    WalletRow(wallet: w)
                }
            }
            Section("Lightning") {
                ForEach(wallets.filter { $0.type == .lightning }) { w in
                    WalletRow(wallet: w)
                }
            }
        }
        .listStyle(.insetGrouped)
    }
}

Forms

struct SendView: View {
    @State private var address = ""
    @State private var amount = ""
    @FocusState private var focusedField: Field?

    enum Field: Hashable {
        case address, amount
    }

    var body: some View {
        Form {
            Section("Recipient") {
                TextField("Bitcoin address", text: $address)
                    .focused($focusedField, equals: .address)
                    .textInputAutocapitalization(.never)
                    .autocorrectionDisabled()
            }
            Section("Amount") {
                TextField("Sats", text: $amount)
                    .focused($focusedField, equals: .amount)
                    .keyboardType(.numberPad)
            }
            Button("Send") { send() }
                .disabled(address.isEmpty || amount.isEmpty)
        }
        .toolbar {
            ToolbarItemGroup(placement: .keyboard) {
                Spacer()
                Button("Done") { focusedField = nil }
            }
        }
    }
}

Pull-to-Refresh

struct WalletList: View {
    @State private var store = WalletStore()

    var body: some View {
        List(store.wallets) { wallet in
            WalletRow(wallet: wallet)
        }
        .refreshable {
            await store.load()
        }
    }
}

Searchable

struct WalletList: View {
    @State private var query = ""
    @State private var store = WalletStore()

    var filtered: [Wallet] {
        query.isEmpty ? store.wallets : store.wallets.filter { $0.name.localizedCaseInsensitiveContains(query) }
    }

    var body: some View {
        List(filtered) { wallet in WalletRow(wallet: wallet) }
            .searchable(text: $query, prompt: "Search wallets")
    }
}

Async Tasks in Views

struct WalletDetail: View {
    let walletId: String
    @State private var balance: Balance?

    var body: some View {
        VStack {
            if let balance {
                Text("\(balance.confirmed) sats")
            } else {
                ProgressView()
            }
        }
        .task(id: walletId) {                              // re-runs when walletId changes
            do {
                balance = try await api.fetchBalance(id: walletId)
            } catch {
                // handle
            }
        }
    }
}

.task { } runs when view appears, cancels when view disappears. task(id:) re-runs when id changes.

Keychain Services (Quick)

import Security

func saveSecret(_ data: Data, account: String, service: String) throws {
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrAccount as String: account,
        kSecAttrService as String: service,
        kSecValueData as String: data,
        kSecAttrAccessible as String: kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
    ]
    SecItemDelete(query as CFDictionary)
    let status = SecItemAdd(query as CFDictionary, nil)
    guard status == errSecSuccess else { throw KeychainError.status(status) }
}

func loadSecret(account: String, service: String) throws -> Data {
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrAccount as String: account,
        kSecAttrService as String: service,
        kSecReturnData as String: true,
        kSecMatchLimit as String: kSecMatchLimitOne,
    ]
    var item: CFTypeRef?
    let status = SecItemCopyMatching(query as CFDictionary, &item)
    guard status == errSecSuccess, let data = item as? Data else {
        throw KeychainError.status(status)
    }
    return data
}

For Keychain access groups, biometric SAC, iCloud sync, see secure-storage.md.

Secure Enclave (Quick)

import CryptoKit
import LocalAuthentication

func generateSEPKey() throws -> SecureEnclave.P256.Signing.PrivateKey {
    let access = SecAccessControlCreateWithFlags(
        nil,
        kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
        [.privateKeyUsage, .biometryCurrentSet],
        nil
    )!

    return try SecureEnclave.P256.Signing.PrivateKey(
        accessControl: access,
        authenticationContext: LAContext()
    )
}

let key = try generateSEPKey()
let signature = try key.signature(for: dataToSign)        // prompts biometric

P-256 only — for wallet seeds, use SEP to wrap/unwrap an AES key, store wrapped seed in Keychain.

Anti-Patterns

Anti-patternWhy it's badCorrect approach
ObservableObject + @Published for new codeRe-renders entire view treeUse @Observable (Swift 5.9+)
@StateObject for @Observable typesWrong wrapperUse @State (since @Observable already manages identity)
Force-unwrap try! outside testsCrash on errorProper do/catch
Task { ... } without [weak self] for long opsRetain cycle[weak self] or use .task { }
Network in view bodyRecomputed on each renderUse .task { } or model
print for production logsNo filter, no privacy redactionLogger(subsystem:category:) with privacy markers
Foundation Date mathTimezone bugsUse Calendar.current.date(byAdding:to:)
String for sensitive secretsInterned, can't wipeUse Data, zero with .resetBytes()
.frame(maxWidth: .infinity) everywhereLayout debugging hellUse proper layout containers
Missing Privacy ManifestApp Store rejectionInclude PrivacyInfo.xcprivacy for app + every framework
UIApplication.shared from SwiftUIBridging without envUse @Environment(\.openURL), etc.
Forgetting task(id:) when input changesStale dataUse id parameter to re-run task

Logging — os.Logger

import os

private let log = Logger(subsystem: "com.bhodl.wallet", category: "send")

log.info("Sending \(amount.sats, privacy: .public) sats to \(address, privacy: .private)")
log.error("Send failed: \(error.localizedDescription, privacy: .public)")
log.fault("Inconsistent state in tx engine")

.privacy levels: .public, .private (default), .private(mask: .hash). Critical for wallet apps — never leak addresses, balances, or seed material in unredacted logs.

View logs in Console.app filtered by subsystem.

Build & Tooling

# Build (Xcode CLI)
xcodebuild -scheme BHODL -configuration Release -sdk iphoneos build

# Test
xcodebuild test -scheme BHODL -destination 'platform=iOS Simulator,name=iPhone 15'

# Archive for App Store
xcodebuild -scheme BHODL archive -archivePath ./build/BHODL.xcarchive

# Export IPA
xcodebuild -exportArchive -archivePath ./build/BHODL.xcarchive \
    -exportPath ./build/ipa -exportOptionsPlist ExportOptions.plist

For SwiftPM-based packages:

swift build
swift test
swift package update

Testing

Swift Testing (Swift 6+ — recommended)

import Testing
@testable import BHODL

@Suite struct WalletStoreTests {
    @Test func loadsWalletsOnInit() async throws {
        let store = WalletStore(api: MockAPI(wallets: [.testWallet()]))
        await store.load()
        #expect(store.wallets.count == 1)
        #expect(store.error == nil)
    }

    @Test(arguments: [0, 1, 100, 1_000_000])
    func formatsAmount(sats: Int) {
        let formatted = formatSats(sats)
        #expect(!formatted.isEmpty)
    }
}

XCTest (legacy, still widely used)

final class WalletStoreTests: XCTestCase {
    func testLoadsWallets() async throws {
        let store = WalletStore(api: MockAPI())
        await store.load()
        XCTAssertEqual(store.wallets.count, 1)
    }
}

UI Tests (XCUITest)

final class BHODLUITests: XCTestCase {
    func testSendFlow() throws {
        let app = XCUIApplication()
        app.launch()

        app.buttons["Send"].tap()
        app.textFields["Bitcoin address"].typeText("bc1q...")
        app.buttons["Continue"].tap()

        XCTAssertTrue(app.staticTexts["Confirm"].waitForExistence(timeout: 2))
    }
}

For snapshot testing: swift-snapshot-testing (PointFree) — popular for SwiftUI views.

When NOT to Use This Skill

ScenarioUse Instead
Compose UI on iOSfrontend-frameworks/compose-multiplatform
Swift language patternslanguages/swift
Rust → Swift bindingslanguages/uniffi
KMP shared module setupmobile/kotlin-multiplatform
Detailed nav/sheets/stateswiftui-architecture.md
Keychain access groups, SEP attestationsecure-storage.md
Background tasks, App Groups, Privacy Manifestsystem-integration.md
Discovery
Tags

No tags published for this skill.

Version
Latest version metadata

Version

v2026.09.24

Published

Sep 24, 2026

Category

Uncategorized

License

MIT

Source path

skills/mobile/ios-native

Default branch

main

Latest commit

9496306

Tree SHA

fe4e2f1