Files
gh-glittercowboy-taches-cc-…/skills/expertise/iphone-apps/references/navigation-patterns.md
2025-11-29 18:28:37 +08:00

11 KiB

Navigation Patterns

NavigationStack, deep linking, and programmatic navigation for iOS apps.

NavigationStack Basics

Value-Based Navigation

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

    var body: some View {
        NavigationStack(path: $path) {
            List(items) { item in
                NavigationLink(value: item) {
                    ItemRow(item: item)
                }
            }
            .navigationTitle("Items")
            .navigationDestination(for: Item.self) { item in
                ItemDetail(item: item, path: $path)
            }
            .navigationDestination(for: Category.self) { category in
                CategoryView(category: category)
            }
        }
    }
}

Programmatic Navigation

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

    var body: some View {
        NavigationStack(path: $path) {
            VStack {
                Button("Go to Settings") {
                    path.append(Route.settings)
                }

                Button("Go to Item") {
                    path.append(items[0])
                }

                Button("Deep Link") {
                    // Push multiple screens
                    path.append(Route.settings)
                    path.append(SettingsSection.account)
                }
            }
            .navigationDestination(for: Route.self) { route in
                switch route {
                case .settings:
                    SettingsView(path: $path)
                case .profile:
                    ProfileView()
                }
            }
            .navigationDestination(for: Item.self) { item in
                ItemDetail(item: item)
            }
            .navigationDestination(for: SettingsSection.self) { section in
                SettingsSectionView(section: section)
            }
        }
    }

    func popToRoot() {
        path.removeLast(path.count)
    }

    func popOne() {
        if !path.isEmpty {
            path.removeLast()
        }
    }
}

enum Route: Hashable {
    case settings
    case profile
}

enum SettingsSection: Hashable {
    case account
    case notifications
    case privacy
}

Tab-Based Navigation

TabView with NavigationStack per Tab

struct MainTabView: View {
    @State private var selectedTab = Tab.home
    @State private var homePath = NavigationPath()
    @State private var searchPath = NavigationPath()
    @State private var profilePath = NavigationPath()

    var body: some View {
        TabView(selection: $selectedTab) {
            NavigationStack(path: $homePath) {
                HomeView()
            }
            .tabItem {
                Label("Home", systemImage: "house")
            }
            .tag(Tab.home)

            NavigationStack(path: $searchPath) {
                SearchView()
            }
            .tabItem {
                Label("Search", systemImage: "magnifyingglass")
            }
            .tag(Tab.search)

            NavigationStack(path: $profilePath) {
                ProfileView()
            }
            .tabItem {
                Label("Profile", systemImage: "person")
            }
            .tag(Tab.profile)
        }
        .onChange(of: selectedTab) { oldTab, newTab in
            // Pop to root when re-tapping current tab
            if oldTab == newTab {
                switch newTab {
                case .home: homePath.removeLast(homePath.count)
                case .search: searchPath.removeLast(searchPath.count)
                case .profile: profilePath.removeLast(profilePath.count)
                }
            }
        }
    }

    enum Tab {
        case home, search, profile
    }
}

Deep Linking

URL Scheme Handling

Configure in Info.plist:

<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleURLSchemes</key>
        <array>
            <string>myapp</string>
        </array>
    </dict>
</array>

Handle in App:

@main
struct MyApp: App {
    @State private var appState = AppState()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(appState)
                .onOpenURL { url in
                    handleDeepLink(url)
                }
        }
    }

    private func handleDeepLink(_ url: URL) {
        // myapp://item/123
        // myapp://settings/account
        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return }

        let pathComponents = components.path.split(separator: "/").map(String.init)

        switch pathComponents.first {
        case "item":
            if let id = pathComponents.dropFirst().first {
                appState.navigateToItem(id: id)
            }
        case "settings":
            let section = pathComponents.dropFirst().first
            appState.navigateToSettings(section: section)
        default:
            break
        }
    }
}

@Observable
class AppState {
    var selectedTab: Tab = .home
    var homePath = NavigationPath()

    func navigateToItem(id: String) {
        selectedTab = .home
        homePath.removeLast(homePath.count)
        if let item = findItem(id: id) {
            homePath.append(item)
        }
    }

    func navigateToSettings(section: String?) {
        selectedTab = .profile
        // Navigate to settings
    }
}

Configure in apple-app-site-association on your server:

{
    "applinks": {
        "apps": [],
        "details": [
            {
                "appID": "TEAMID.com.yourcompany.app",
                "paths": ["/item/*", "/user/*"]
            }
        ]
    }
}

Add Associated Domains entitlement:

<key>com.apple.developer.associated-domains</key>
<array>
    <string>applinks:example.com</string>
</array>

Handle same as URL schemes with onOpenURL.

Modal Presentation

Sheet Navigation

struct ContentView: View {
    @State private var selectedItem: Item?
    @State private var showingNewItem = false

    var body: some View {
        NavigationStack {
            List(items) { item in
                Button(item.name) {
                    selectedItem = item
                }
            }
            .toolbar {
                Button {
                    showingNewItem = true
                } label: {
                    Image(systemName: "plus")
                }
            }
        }
        // Item-based presentation
        .sheet(item: $selectedItem) { item in
            NavigationStack {
                ItemDetail(item: item)
                    .toolbar {
                        ToolbarItem(placement: .cancellationAction) {
                            Button("Done") {
                                selectedItem = nil
                            }
                        }
                    }
            }
        }
        // Boolean-based presentation
        .sheet(isPresented: $showingNewItem) {
            NavigationStack {
                NewItemView()
                    .toolbar {
                        ToolbarItem(placement: .cancellationAction) {
                            Button("Cancel") {
                                showingNewItem = false
                            }
                        }
                    }
            }
        }
    }
}

Full Screen Cover

.fullScreenCover(isPresented: $showingOnboarding) {
    OnboardingFlow()
}

Detents (Sheet Sizes)

.sheet(isPresented: $showingOptions) {
    OptionsView()
        .presentationDetents([.medium, .large])
        .presentationDragIndicator(.visible)
}

Navigation State Persistence

Codable Navigation Path

struct ContentView: View {
    @State private var path: [Route] = []

    var body: some View {
        NavigationStack(path: $path) {
            // Content
        }
        .onAppear {
            loadNavigationState()
        }
        .onChange(of: path) { _, newPath in
            saveNavigationState(newPath)
        }
    }

    private func saveNavigationState(_ path: [Route]) {
        if let data = try? JSONEncoder().encode(path) {
            UserDefaults.standard.set(data, forKey: "navigationPath")
        }
    }

    private func loadNavigationState() {
        guard let data = UserDefaults.standard.data(forKey: "navigationPath"),
              let savedPath = try? JSONDecoder().decode([Route].self, from: data) else {
            return
        }
        path = savedPath
    }
}

enum Route: Codable, Hashable {
    case item(id: UUID)
    case settings
    case profile
}

Navigation Coordinator

For complex apps, centralize navigation logic:

@Observable
class NavigationCoordinator {
    var homePath = NavigationPath()
    var searchPath = NavigationPath()
    var selectedTab: Tab = .home

    enum Tab {
        case home, search, profile
    }

    func showItem(_ item: Item) {
        selectedTab = .home
        homePath.append(item)
    }

    func showSearch(query: String) {
        selectedTab = .search
        searchPath.append(SearchQuery(text: query))
    }

    func popToRoot() {
        switch selectedTab {
        case .home:
            homePath.removeLast(homePath.count)
        case .search:
            searchPath.removeLast(searchPath.count)
        case .profile:
            break
        }
    }

    func handleDeepLink(_ url: URL) {
        // Parse and navigate
    }
}

// Inject via environment
@main
struct MyApp: App {
    @State private var coordinator = NavigationCoordinator()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(coordinator)
                .onOpenURL { url in
                    coordinator.handleDeepLink(url)
                }
        }
    }
}

Search Integration

Searchable Modifier

struct ItemList: View {
    @State private var searchText = ""
    @State private var searchScope = SearchScope.all

    var filteredItems: [Item] {
        items.filter { item in
            searchText.isEmpty || item.name.localizedCaseInsensitiveContains(searchText)
        }
    }

    var body: some View {
        NavigationStack {
            List(filteredItems) { item in
                NavigationLink(value: item) {
                    ItemRow(item: item)
                }
            }
            .navigationTitle("Items")
            .searchable(text: $searchText, prompt: "Search items")
            .searchScopes($searchScope) {
                Text("All").tag(SearchScope.all)
                Text("Recent").tag(SearchScope.recent)
                Text("Favorites").tag(SearchScope.favorites)
            }
            .navigationDestination(for: Item.self) { item in
                ItemDetail(item: item)
            }
        }
    }

    enum SearchScope {
        case all, recent, favorites
    }
}

Search Suggestions

.searchable(text: $searchText) {
    ForEach(suggestions) { suggestion in
        Text(suggestion.text)
            .searchCompletion(suggestion.text)
    }
}