Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 

Repository files navigation

NavigationKit

Programmatic SwiftUI navigation: routes as types (Routable), stack in Router, shell in NavigationHost.

iOS 15+ · Swift 6

iOS Mechanism
16+ NavigationStack + navigationDestination
15 NavigationView + hidden NavigationLink chain

Installation

XcodeGen (project.yml)

packages:
  NavigationKit:
    path: ../NavigationKit

targets:
  MyApp:
    dependencies:
      - package: NavigationKit

  # previews / tests only:
  MyAppPreviews:
    dependencies:
      - package: NavigationKit
        product: NavigationKitMocks

Products

Product Purpose
NavigationKit Router, NavigationHost, protocols
NavigationKitMocks MockNavigation for SwiftUI Previews

Concepts

App entry
   └── NavigationHost(router:)
           ├── rootView  ← SplashRouter.makeView()
           └── stack[]   ← push: MainRouter, PaywallRouter, …
Type Role
Routable Hashable + makeView() — one screen
Navigable route / routeBack / replaceRoot / routeToRoot
Router Stack + root, ObservableObject
*Router (in app) Knows destinations; holds Navigable

DI, deeplinks, and splash logic stay in the app target, not in this package.


Example: from @main to a screen

1. AppRouter (in the app)

import NavigationKit
import SwiftUI

@MainActor
final class AppRouter: Router {
    init(appDelegate: AppDelegate) {
        super.init(root: SplashRouter(navigation: self))
        setupDeeplinks(appDelegate)
    }
}

extension AppRouter: Navigable {
    // route(to:), routeBack(), … inherited from Router
}

Router already conforms to Navigable. Subclass for deeplinks, Factory, etc.

2. Feature router (Splash)

import NavigationKit
import SwiftUI

@MainActor
final class SplashRouter {
    private let navigation: Navigable

    init(navigation: Navigable) {
        self.navigation = navigation
    }

    func routeToMain() {
        navigation.replaceRoot(with: MainRouter(navigation: navigation))
    }
}

extension SplashRouter: Routable {
    func makeView() -> AnyView {
        let router = self
        let feature = SplashFeature(router: router)
        return AnyView(SplashView(feature: feature))
    }
}

extension SplashRouter: Equatable {
    static func == (lhs: SplashRouter, rhs: SplashRouter) -> Bool {
        lhs === rhs
    }

    func hash(into hasher: inout Hasher) {
        hasher.combine(ObjectIdentifier(self))
    }
}

For class routers, Hashable is usually ObjectIdentifier(self).

3. MainRouter — push on top of root

@MainActor
final class MainRouter {
    private let navigation: Navigable

    init(navigation: Navigable) {
        self.navigation = navigation
    }

    func showPaywall() {
        navigation.route(to: PaywallRouter(navigation: navigation))
    }
}

extension MainRouter: Routable {
    func makeView() -> AnyView {
        AnyView(MainView(feature: MainFeature(router: self)))
    }
}

extension MainRouter: Equatable {
    static func == (lhs: MainRouter, rhs: MainRouter) -> Bool { lhs === rhs }
    func hash(into hasher: inout Hasher) { hasher.combine(ObjectIdentifier(self)) }
}

4. Paywall — dismiss via pop

@MainActor
final class PaywallRouter: Routable {
    private let navigation: Navigable

    init(navigation: Navigable) {
        self.navigation = navigation
    }

    func close() {
        navigation.routeBack()
    }

    func makeView() -> AnyView {
        AnyView(PaywallView(feature: PaywallFeature(router: self)))
    }
}

5. MainApp + NavigationHost

import NavigationKit
import SwiftUI

@main
struct MainApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate

    private let router = AppRouter(appDelegate: AppDelegate.shared)

    var body: some Scene {
        WindowGroup {
            NavigationHost(
                router: router,
                preferredColorScheme: .dark,
                navigationBarColorScheme: .dark,
                hidesTabBar: true
            )
        }
    }
}

Navigation operations

// Push onto the stack
navigation.route(to: PaywallRouter(navigation: navigation))

// Pop one screen
navigation.routeBack()

// Clear stack (stay on current root)
navigation.routeToRoot()

// Replace root (e.g. splash → main)
navigation.replaceRoot(with: MainRouter(navigation: navigation))

All methods update router.stack. On iOS 16+, NavigationHost syncs an internal NavigationPath.


SwiftUI Preview

import NavigationKit
import NavigationKitMocks
import SwiftUI

#Preview {
    let navigation = MockNavigation()
    let router = SplashRouter(navigation: navigation)
    return SplashView(feature: SplashFeature(router: router))
}

MockNavigation is a no-op — useful for layout without a real stack.


NavigationHost parameters

NavigationHost(
    router: router,
    preferredColorScheme: .dark,      // optional
    navigationBarColorScheme: .dark,  // iOS 16+: toolbarColorScheme; iOS 15: UINavigationBar
    hidesTabBar: true                  // UITabBar.appearance().isHidden
)

Struct router (no class)

struct SettingsRoute: Routable {
    let navigation: Navigable

    func makeView() -> AnyView {
        AnyView(SettingsView(onClose: { navigation.routeBack() }))
    }
}

// Hashable is synthesized when all stored properties are Hashable

App-specific: deeplink

@MainActor
final class AppRouter: Router {
    private var deeplinkService: DeeplinkService

    init(deeplinkService: DeeplinkService) {
        self.deeplinkService = deeplinkService
        super.init(root: SplashRouter(navigation: self))

        observeDeeplinks()
    }

    private func observeDeeplinks() {
        Task { [weak self] in
            for await url in deeplinkService.urls {
                self?.handle(url)
            }
        }
    }

    private func handle(_ url: URL) {
        switch url.host {
        case "paywall":
            route(to: PaywallRouter(navigation: self))
        default:
            break
        }
    }
}

iOS 15 vs 16

iOS 15 iOS 16+
Container NavigationView NavigationStack
Programmatic push hidden NavigationLink stack → NavigationPath
Swipe back isActive → trim stack path.count → trimStack
App API same Navigable same Navigable

Stack behavior on iOS 15 is slightly less flexible for deep nesting; typical route / routeBack / replaceRoot flows work well.


API reference

Protocols

public typealias Routable = ViewFactory & Hashable

public protocol ViewFactory {
    @MainActor func makeView() -> AnyView
}

public protocol Navigable: AnyObject {
    @MainActor func route(to route: any Routable)
    @MainActor func replaceRoot(with route: any Routable)
    @MainActor func routeBack()
    @MainActor func routeToRoot()
}

Router

Member Description
stack Current push stack ([AnyRoutable])
rootView Root AnyView
init(root:) Initial route
setRoot(_:) Update root view

AnyRoutable

Type eraser for NavigationStack / navigationDestination(for:).

NavigationKitMocks

public final class MockNavigation: Navigable
// + replace(with:) — no-op, for legacy compatibility

Using with FeatureKit

// SplashRouter builds Feature + View
func makeView() -> AnyView {
    AnyView(SplashView(feature: SplashFeature(router: self)))
}

// Feature triggers navigation
func onLoaded() {
    router.routeToMain()
}

FeatureKit and NavigationKit do not depend on each other.


Package layout

NavigationKit/
├── Package.swift
└── Sources/
    ├── NavigationKit/
    │   ├── Routable.swift
    │   ├── AnyRoutable.swift
    │   ├── Router.swift
    │   ├── NavigationHost.swift
    │   ├── ModernNavigationHost.swift   # iOS 16+
    │   └── LegacyNavigationHost.swift   # iOS 15
    └── NavigationKitMocks/
        └── MockNavigation.swift

Migrating from a monolith

  1. Add the package in project.yml.
  2. import NavigationKit in routers and MainApp.
  3. Subclass Router as AppRouter, or compose Router and forward Navigable.
  4. Replace AppNavigationView with NavigationHost(router: appRouter, ...).
  5. Remove local duplicates of AnyRoutable and push logic that Router already provides.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages