SwiftfulRouting Documentation

repository·main·Indexed 21 days ago

https://github.com/swiftfulthinking/swiftfulrouting

A programmatic navigation framework for SwiftUI that replaces standard declarative navigation with an imperative approach. It provides a RouterView to manage complex screen stacks, supporting native segues (push, sheet, fullScreenCover), alerts, confirmation dialogs, and custom modals. Features include a screen and transition queue for deferred navigation, precise dismissal controls, and support for iOS 13+ (v2.0.2), iOS 14+ (v5.3.6), and iOS 17+ (v6.0+).

Tokens
6.9K
Snippets
15
Records
16
Agent score
26%

What's inside SwiftfulRouting

  1. Manage a transition queue

    main

    Similar to the screen queue, you can add transitions to a queue to trigger them later. This is useful for complex, multi-step view changes that aren't full segues.

    Transition Queue Operations:

    • addTransitionToQueue(transition:): Adds a single transition to the queue.
    • addTransitionsToQueue(transitions:): Adds multiple transitions to the queue.
    • showNextTransition(): Triggers the first transition in the queue if available.
    • tryShowNextTransition(): Same as above, but throws an error if the queue is empty.
    • removeTransitionFromQueue(id:): Removes a specific transition from the queue.
    • removeTransitionsFromQueue(ids:): Removes multiple specific transitions from the queue.
    • removeAllTransitionsFromQueue(): Clears the entire queue.

    Convenience Method:

    • showNextTransitionOrNextScreenOrDismissScreen(): Triggers the next transition; if none, triggers the next screen; if neither, dismisses the screen.
    router.addTransitionToQueue(transition: screen1)
    router.addTransitionsToQueue(transitions: [screen1, screen2])
    
    // Trigger next
    router.showNextTransition()
    
    do {
        try router.tryShowNextTransition()
    } catch {
        // Handle error
    }
    
    router.removeTransitionFromQueue(id: "x")
    router.removeAllTransitionsFromQueue()
    
    // Convenience
    router.showNextTransitionOrNextScreenOrDismissScreen()
  2. Use transitions to change screens without segues

    main

    Transitions allow you to change the current screen WITHOUT performing a full segue. They behave similarly to an if-else statement switching between views.

    Important: Transition Behavior When showing a screen via showScreen, the transitionBehavior parameter determines how subsequent showTransition calls behave on that screen:

    • .keepPrevious: Keeps previous screens in memory. New screens transition ON TOP of existing ones.
    • .removePrevious: Removes previous screens from memory. The new screen transitions ON while the old one transitions OFF.

    Transition Management:

    • showTransition { router in ... }: Basic transition.
    • showTransitions(transitions:): Displays multiple transitions, with the last one on top.
    • dismissTransition(): Dismisses the last transition.
    • dismissTransition(id:): Dismisses a specific transition by ID.
    • dismissTransitions(upToId:): Dismisses transitions above, but not including, the specified ID.
    • dismissTransitions(count:): Dismisses a specific number of transitions.
    • dismissAllTransitions(): Dismisses all transitions.
    • dismissTransitionOrDismissScreen(): Dismisses the current transition; if none exists, dismisses the screen.
    // Show screen with behavior that affects future transitions
    router.showScreen(transitionBehavior: .removePrevious) { _ in
        MyView()
    }
    
    // Perform a transition
    router.showTransition { router in
        MyView()
    }
    
    // Custom transition
    let transition = AnyTransitionDestination(
        id: "transition_1",
        transition: .trailing,
        allowsSwipeBack: true,
        onDismiss: { /* action */ },
        destination: { router in MyView() }
    )
    router.showTransition(transition: transition)
    
    // Dismissing
    router.dismissTransition(id: "transition_1")
    router.dismissTransitionOrDismissScreen()
  3. How SwiftfulRouting works

    main

    SwiftfulRouting enables programmatic navigation by adding a set of view modifiers to the root of destination views. This allows declarative code to behave like programmatic code because the modifiers are connected in advance. Screen destinations are erased to generic types, allowing the destination to be determined at runtime.

    Router Hierarchy and Scoping

    • Environment Access: Every child view can access a router via the @Environment(\.router) property wrapper.
    • Direct Injection: You can pass the router directly into child views via the RouterView closure: RouterView { router in MyView(router: router) }.
    • Unique Routers: A new, unique router is created and added to the hierarchy after every segue. This means a router instance is specific to a particular screen. For example, calling dismissScreen() on a router from a child view will dismiss that specific screen and its context, whereas calling it on a parent's router might dismiss multiple screens in the stack.
  4. How Modules work in SwiftfulRouting

    main

    Modules allow you to swap the entire view hierarchy by replacing the existing RouterView with a new one. This is useful for major state changes like moving from an Onboarding flow to a Main App flow.

    Key Requirements

    • Enable Support: Module support is not enabled by default. You must set addModuleSupport: true on your RouterView.
    • Required ID: When addModuleSupport is true, you must provide an id parameter for analytics tracking. Failure to provide an ID will trigger an assertion failure in debug builds.

    Usage Patterns

    • Basic Swap: Use router.showModule { router in ... } to replace the current view.
    • Custom Transitions: You can use AnyTransitionDestination to fully customize the module's display, including the transition edge, swipe-back gestures, and dismissal callbacks.
    • State Restoration: The user's last module ID can be saved to UserDefaults to restore the app state across sessions.
    // ✅ Correct - ID provided when addModuleSupport is true
    RouterView(id: "home", addModuleSupport: true) { _ in
        MyView()
    }
    
    // Customizing a module transition
    let module = AnyTransitionDestination(
        id: "module_1",
        transition: .trailing,
        allowsSwipeBack: true,
        onDismiss: {
            // Do something when transition dismisses
        },
        destination: { router in
            MyView()
        }
    )
    
    router.showModule(module: module)
  5. Manage a screen queue for deferred navigation

    main

    You can add screens to a queue to navigate to them at a later time. This is useful for flows like onboarding where the next step depends on user input.

    Queue Operations:

    • addScreenToQueue(destination:): Adds a single screen to the queue.
    • addScreensToQueue(destinations:): Adds multiple screens to the queue.
    • showNextScreen(): Triggers a segue to the first screen in the queue if available.
    • tryShowNextScreen(): Same as above, but throws an error if the queue is empty.
    • removeScreenFromQueue(id:): Removes a specific screen from the queue.
    • removeScreensFromQueue(ids:): Removes multiple specific screens from the queue.
    • removeAllScreensFromQueue(): Clears the entire queue.

    Convenience Methods:

    • showNextScreenOrDismissScreen(): Segues to the next screen in the queue; if empty, dismisses the current screen.
    • showNextScreenOrDismissEnvironment(): Segues to the next screen; if empty, dismisses the environment.
    • showNextScreenOrDismissPushStack(): Segues to the next screen; if empty, dismisses the push stack.
    router.addScreenToQueue(destination: screen1)
    router.addScreensToQueue(destinations: [screen1, screen2, screen3])
    
    // Show next screen if available
    router.showNextScreen()
    
    // Show next screen, otherwise, throw error
    do {
        try router.tryShowNextScreen()
    } catch {
        // Handle error
    }
    
    router.removeScreenFromQueue(id: "x")
    router.removeAllScreensFromQueue()
    
    // Convenience
    router.showNextScreenOrDismissScreen()
  6. Install and Setup SwiftfulRouting

    main

    To use SwiftfulRouting, add the package to your Xcode project using the following URL:

    https://github.com/SwiftfulThinking/SwiftfulRouting.git

    Version Compatibility

    • iOS 17+: Use version 6.0 or above
    • iOS 14+: Use version 5.3.6
    • iOS 13+: Use version 2.0.2

    Initial Setup

    1. Import the package: import SwiftfulRouting
    2. Replace your existing NavigationStack with a RouterView at the top of your view hierarchy. RouterView embeds a NavigationStack by default and adds the necessary modifiers to support all potential segues.

    If you cannot remove your existing NavigationStack, you can initialize RouterView without adding a new one:

    RouterView(addNavigationStack: false) { router in
       MyView()
            .navigationBarHidden(true)
    }
    import SwiftfulRouting
    
    RouterView { router in
        MyView()
    }
  7. Re-enable native swipe-back gesture

    main

    SwiftfulRouting uses UINavigationController under the hood. By default, SwiftUI's override of the interactive pop gesture recognizer disables the native edge-swipe-back gesture. To re-enable it globally, add the following UINavigationController extension to your project.

    This extension also provides a global toggle UINavigationController.setSwipeBack(enabled:) to temporarily disable the gesture on specific screens (e.g., when using horizontal carousels or pagers).

    // Extensions/UINavigationController+EXT.swift
    import Foundation
    
    extension UINavigationController: @retroactive UIGestureRecognizerDelegate {
        override open func viewDidLoad() {
            super.viewDidLoad()
            interactivePopGestureRecognizer?.delegate = self
        }
    
        public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
            guard UINavigationController.allowsSwipeBack else {
                return false
            }
    
            return viewControllers.count > 1
        }
    
        static private(set) var allowsSwipeBack: Bool = true
    
        static func setSwipeBack(enabled: Bool) {
            allowsSwipeBack = enabled
        }
    }

    To disable swipe-back on a specific screen:

    .onAppear { UINavigationController.setSwipeBack(enabled: false) }
    .onDisappear { UINavigationController.setSwipeBack(enabled: true) }
  8. Implement TabBar and App Structure with RouterView

    main

    When building apps with TabView, you must decide whether to use a single NavigationStack for the entire app or individual stacks for each tab.

    For a robust architecture, use a parent RouterView to manage high-level modules (like Onboarding vs. Main App) and individual RouterView instances within each tab to manage tab-specific navigation.

    • Root RouterView: Set addNavigationStack: false and addModuleSupport: true to act as a container for the TabBar.
    • Tab RouterViews: Set addNavigationStack: true and addModuleSupport: false to provide independent navigation stacks for each tab.
    struct AppRootView: View {
        @State private var lastModuleId = UserDefaults.lastModuleId
    
        @ViewBuilder
        var body: some View {
            if lastModuleId == "tabbar" {
                // Root manages modules, but doesn't add its own NavStack
                RouterView(id: "tabbar", addNavigationStack: false, addModuleSupport: true) { _ in
                    AppTabbarView()
                }
            } else {
                // Onboarding module
                RouterView(id: "onboarding", addModuleSupport: true) { router in
                    OnboardingView()
                }
            }
        }
    }
    
    struct AppTabbarView: View {
        var body: some View {
            TabView {
                // Each tab gets its own RouterView with a NavStack
                RouterView(addNavigationStack: true, addModuleSupport: false, content: { _ in
                    Text("Screen1")
                })
                .tabItem { Label("Home", systemImage: "house.fill") }
                
                RouterView(addNavigationStack: true, addModuleSupport: false, content: { _ in
                    Text("Screen2")
                })
                .tabItem { Label("Search", systemImage: "magnifyingglass") }
            }
        }
    }
  9. Manage Modules with router.showModule and dismiss methods

    main

    Use the following methods on the router instance to manage module-level navigation:

    • showModule { router in ... }: Replaces the current view hierarchy with a new module.
    • showModule(module: AnyTransitionDestination): Replaces the hierarchy using a pre-configured transition destination.
    • showModules(modules: [AnyTransitionDestination]): Displays a sequence of modules, showing the last one.
    • dismissModule(): Dismisses the last displayed module.
    • dismissModule(id: String): Dismisses a specific module by its ID.
    • dismissModules(upToId: String): Dismisses all modules above, but not including, the specified ID.
    • dismissModules(count: Int): Dismisses a specific number of modules.
    • dismissAllModules(): Dismisses all modules.
    // Example: Dismissing screens before switching modules for better UX
    Task {
      router.dismissAllScreens()
      try? await Task.sleep(for: .seconds(1))
      router.showModule { router in
        MyView()
      }
    }
  10. Show and manage multiple modals

    main

    SwiftfulRouting supports an infinite number of simultaneous modals that appear on top of the current screen. Modals can be highly customized regarding transitions, animations, alignment, and background effects.

    Modal Customization: When using showModal, you can specify:

    • id: A unique identifier for the modal.
    • transition: An AnyTransition (e.g., .move(edge: .bottom)).
    • animation: The transition animation (e.g., .smooth).
    • alignment: The alignment within the screen.
    • backgroundColor & backgroundEffect: Visual styling for the layer behind the modal.
    • dismissOnBackgroundTap: Whether tapping the background dismisses the modal.
    • ignoreSafeArea: Whether the modal should ignore safe area insets.
    • onDismiss: A closure executed when the modal is dismissed.

    Modal Management:

    • showModal { ... }: Basic modal display.
    • showModals(modals:): Triggers multiple modals simultaneously.
    • dismissModal(): Dismisses the last displayed modal.
    • dismissModal(id:): Dismisses a specific modal by ID.
    • dismissModals(upToModalId:): Dismisses modals above, but not including, the specified ID.
    • dismissModals(count:): Dismisses a specific number of modals.
    • dismissAllModals(): Dismisses all active modals.

    Convenience Methods:

    • showBasicModal { ... }: Quick modal display.
    • showBottomModal { ... }: Modal that appears from the bottom.
    // Fully customized modal
    router.showModal(
        id: "modal_1",
        transition: .move(edge: .bottom),
        animation: .smooth,
        alignment: .center,
        backgroundColor: Color.black.opacity(0.1),
        backgroundEffect: BackgroundEffect(effect: UIBlurEffect(style: .systemMaterialDark), intensity: 0.1),
        dismissOnBackgroundTap: true,
        ignoreSafeArea: true,
        onDismiss: {
            print("Dismissed")
        },
        destination: {
            MyModal()
        }
    )
    
    // Using AnyModal
    let modal = AnyModal { MyModal() }
    router.showModal(modal: modal)
    
    // Dismissing
    router.dismissModal(id: "modal_1")
    router.dismissModals(count: 2)
    router.dismissAllModals()
  11. Dismiss screens using the router

    main

    The router provides several methods to dismiss screens, allowing for precise control over the navigation hierarchy. You can dismiss a single screen, dismiss up to a specific ID, or dismiss a specific count of screens.

    Key methods include:

    • dismissScreen(): Dismisses the current screen.
    • dismissScreen(id:): Dismisses the screen with the specified ID.
    • dismissScreen(upToScreenId:): Dismisses screens back to, but not including, the specified ID.
    • dismissScreens(count:): Dismisses a specific number of screens.
    • dismissPushStack(): Dismisses all .push segues on the current NavigationStack.
    • dismissEnvironment(): Dismisses the closest environment (e.g., .sheet or .fullScreenCover).
    • dismissLastScreen(): Dismisses the last screen in the hierarchy.
    • dismissLastPushStack(): Dismisses the last push stack in the hierarchy.
    • dismissLastEnvironment(): Dismisses the last environment in the hierarchy.
    • dismissAllScreens(): Dismisses all screens in the hierarchy.
    router.dismissScreen()
    router.dismissScreen(id: "x")
    router.dismissScreen(upToScreenId: "x")
    router.dismissScreens(count: 2)
    router.dismissPushStack()
    router.dismissEnvironment()
    router.dismissLastScreen()
    router.dismissLastPushStack()
    router.dismissLastEnvironment()
    router.dismissAllScreens()
  12. Show screens and segues with router.showScreen()

    main

    The router provides methods to trigger all native SwiftUI segues programmatically.

    Basic Segues

    • Push: router.showScreen(.push) { _ in ... }
    • Sheet: router.showScreen(.sheet) { _ in ... }
    • FullScreenCover: router.showScreen(.fullScreenCover) { _ in ... }

    Using AnyDestination

    You can use AnyDestination for more complex configurations or to pass a pre-defined destination object:

    let screen = AnyDestination(segue: .push, destination: { router in
        Text("Hello, world!")
    })
    
    router.showScreen(screen)

    Multiple Screens

    To trigger multiple screens in sequence (e.g., pushing a screen and then immediately presenting a sheet), use showScreens(destinations:):

    router.showScreens(destinations: [screen1, screen2, screen3])

    Customizing Segues

    You can fully customize a segue using the AnyDestination initializer, which includes:

    • id: A unique identifier (useful for analytics).
    • segue: The type of segue (e.g., .push, .sheet).
    • location: Where to add the screen in the hierarchy (e.g., .insert).
    • animates: Boolean to enable/disable animation.
    • onDismiss: A closure executed when the screen is dismissed.
    • destination: The view to be presented.
    // Example: Showing a sheet with custom configuration
    let config = ResizableSheetConfig(
        detents: [.medium, .large],
        dragIndicator: .visible
    )
    
    router.showScreen(.sheetConfig(config: config)) { _ in
        Text("Screen2")
    }