Skip to content

SwiftfulThinking/SwiftfulRouting

Repository files navigation

❤️ This package is provided for free. Tips appreciated: https://www.buymeacoffee.com/nicksarno

🚀 Learn how to build and use this package: https://www.swiftful-thinking.com/architecture

SwiftfulRouting 🤙

SwiftfulRouting is a native, declarative framework that enables programmatic navigation in SwiftUI applications.

How It Works

Details (Click to expand)
Routers based on programatic code do not declare the view heirarchy in advance, but rather at the time of execution. However, SwiftUI is declarative, and so we must declare the view heirarchy in advance. The solution herein is to convert SwiftUI's declarative code to behave as programmatic code by connecting view modifiers to support the routing in advance.

As you segue to a new screen, the framework adds a set view modifiers to the root of the destination View that will support all potential navigation routes. The modifiers are based on generic and/or type-erased destinations, which maintains a declarative view heirarchy while allowing the developer to still determine the destination at the time of execution.

  • The ViewModifiers are in RouterView.swift -> body.
  • Accessible routing methods are in AnyRouter.swift.
  • Refer to the sample project for example implementations, UI Tests and sample MVC, MVVM and VIPER design patterns.

Sample project: https://github.com/SwiftfulThinking/SwiftfulRoutingExample

Setup

Details (Click to expand)
Add the package to your Xcode project.
https://github.com/SwiftfulThinking/SwiftfulRouting.git

Import the package

import SwiftfulRouting

Add a RouterView at the top of your view heirarchy. A RouterView will embed your view into a Navigation heirarchy and add modifiers to support all potential segues.

struct ContentView: View {
    var body: some View {
        RouterView { _ in
            MyView()
        }
    }
}

All child views have access to a Router in the Environment.

@Environment(\.router) var router
    
var body: some View {
     Text("Hello, world!")
          .onTapGesture {
               router.showScreen(.push) { _ in
                    Text("Another screen!")
               }
          }
     }
}

Instead of relying on the Environment, you may also pass the Router directly into the child views. This allows the Router to be fully decoupled from the View (for more complex app architectures).

RouterView { router in
     ContentView(router: router)
          .onTapGesture {
               router.showScreen(.push) { router2 in
                    Text("View2")
                         .onTapGesture {
                              router2.showScreen(.push) { router3 in
                                   Text("View3")
                              }
                         }
               }
          }
}

A new Router is created and added to the view heirarchy after each Segue. Refer to AnyRouter.swift to see all accessible methods.

Setup (existing projects)

Details (Click to expand)

In order to enter the framework's view heirarchy, you must wrap your content in a RouterView. By default, your view will be wrapped in with navigation stack (iOS 16+ uses a NavigationStack, iOS 15 and below uses NavigationView).

  • If your view is already within a navigation heirarchy, set addNavigationView to FALSE.
  • If your view is already within a NavigationStack, use screens to bind to the existing stack path.
  • The framework uses the native SwiftUI navigation bar, so all related modifiers will still work.
RouterView(addNavigationView: false, screens: $existingStack) { router in
   MyView(router: router)
        .navigationBarHidden(true)
        .toolbar {
        }
}

Show Screens

Details (Click to expand)

Router supports all native SwiftUI segues.

// NavigationLink
router.showScreen(.push) { _ in
     Text("View2")
}

// Sheet
router.showScreen(.sheet) { _ in
     Text("View2")
}

// FullScreenCover
router.showScreen(.fullScreenCover) { _ in
     Text("View2")
}

Segue methods also accept AnyRoute as a convenience, which make it easy to pass the Route around your code.

let route = AnyRoute(.push, destination: { router in
     Text("Hello, world!")
})
                        
router.showScreen(route)

All segues have an onDismiss method.

router.showScreen(.push, onDismiss: {
     // dismiss action
}, destination: { _ in
     Text("Hello, world!")
})
                
let route = AnyRoute(.push, onDismiss: {
     // dismiss action
}, destination: { _ in
     Text("Hello, world!")
})
                
router.showScreen(route)

iOS 16+ uses NavigationStack, which supports pushing multiple screens at once.

let route1 = PushRoute(destination: { router in
     Text("View1")
})
let route2 = PushRoute(destination: { router in
     Text("View2")
})
let route3 = PushRoute(destination: { router in
     Text("View3")
})
                        
router.pushScreenStack(destinations: [route1, route2, route3])

iOS 16+ also supports resizable sheets.

router.showResizableSheet(sheetDetents: [.medium, .large], selection: nil, showDragIndicator: true) { _ in
     Text("Hello, world!)
}

Additional convenience methods:

router.showSafari {
     URL(string: "https://www.apple.com")
}

Enter Screen Flows

Details (Click to expand)

Screen "flows" are new way to support dynamic routing in your application. When you enter a "screen flow", you add an array of Routes to the heirarchy. The application will immediately segue to the first screen, and then set the remaining screens into a queue.

router.enterScreenFlow([
     AnyRoute(.fullScreenCover, destination: screen1),
     AnyRoute(.push, destination: screen2),
     AnyRoute(.push, destination: screen3),
     AnyRoute(.push, destination: screen4),
])

This allows the developer to set multiple future segues at once, without requiring screen-specific code in each child view. Each child view's routing logic is simple as "try to go to next screen".

do {
     try router.showNextScreen()
} catch {
     // There is no next screen set in the flow
     // Dismiss the flow (see below dismiss methods) or do something else
}

Benefits of using a "flow":

  • Simiplified Logic: In most applications, the routing logic is tightly coupled to the View (ie. when you create a screen, you declare in code exactly what the next screen must be). Now, you can build a screen without having to worry about routing at all. Simply support "go to next screen" or "dismiss flow" (see dismissal code below).

  • AB Tests: Each user can see a unique flow of screens in your app, and you don't have to write 'if-else' logic within every child view.

  • High-Level Control: You can control the entire flow from one method, which will be closer to the business logic of your app, rather than within the View itself.

  • Flows on Flows: Flows are fully dynamic, meaning you can enter flows from within flows and can dismiss screens within flows (back-forward-back) without corrupting the flow.

Dismiss Screens

Details (Click to expand)

Dismiss one screen. You can also dismiss a screen using native SwiftUI code, including swipe-back gestures or presentationMode.

router.dismissScreen()

Dismiss all screens pushed onto the stack. This dismisses every "push" (NavigationLink) on the screen's Navigation Stack. This does not dismiss sheet or fullScreenCover.

router.dismissScreenStack()

Dismiss screen environment. This dismisses the screen's root environment (if there is one to dismiss), which is the closest 'sheet' or fullScreenCover below the call-site.

router.dismissEnvironment()

For example, if you entered the following screen flow and you called dismissEnvironment from any of the child views, it would dismiss the fullScreenCover, which in-turn dismisses every view displayed on that Environment.

router.enterScreenFlow([
     AnyRoute(.fullScreenCover, destination: screen1),
     AnyRoute(.push, destination: screen2),
     AnyRoute(.push, destination: screen3),
     AnyRoute(.push, destination: screen4),
])

Logic for dismissing a "Flow" can generally look like:

do {
     try router.showNextScreen()
} catch {
     router.dismissEnvironment()
}

Or convenience method:

router.showNextScreenOrDismissEnvironment()

Copy and paste this code into your project to enable swipe back gestures. This is not included in the SwiftUI framework by default and therefore is not automatically included herein.

extension UINavigationController: UIGestureRecognizerDelegate {
    override open func viewDidLoad() {
        super.viewDidLoad()
        interactivePopGestureRecognizer?.delegate = self
    }
    
    public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        return viewControllers.count > 1
    }
}

Alerts

Details (Click to expand)

Router supports native SwiftUI alerts.

// Alert
router.showAlert(.alert, title: "Title goes here", subtitle: "Subtitle goes here!") {
     Button("OK") {

     }
     Button("Cancel") {
                        
     }
}

// Confirmation Dialog
router.showAlert(.confirmationDialog, title: "Title goes here", subtitle: "Subtitle goes here!") {
     Button("A") {
                        
     }
     Button("B") {
                        
     }
     Button("C") {
                        
     }
}

Dismiss an alert.

router.dismissAlert()

Additional convenience methods:

router.showBasicAlert(text: "Error")

Modals

Details (Click to expand)

Router also supports any modal transition, which displays above the current content. Customize transition, animation, background color/blur, etc. See sample project for example implementations.

router.showModal(transition: .move(edge: .top), animation: .easeInOut, alignment: .top, backgroundColor: nil, useDeviceBounds: true) {
     Text("Sample")
          .onTapGesture {
               router.dismissModal()
          }
}

You can display multiple modals simultaneously. Modals have an optional ID field, which can later be used to dismiss the modal.

router.showModal(id: "top1") {
     Text("Sample")
}

// Dismiss top-most modal
router.dismissModal()

// Dismiss modal by ID
router.dismissModal(id: "top1")

// Dismiss all modals
router.dismissAllModals()

Additional convenience methods:

router.showBasicModal {
     Text("Sample")
          .onTapGesture {
               router.dismissModal()
          }
}

Contribute 🤓

Details (Click to expand)

Community contributions are encouraged! Please ensure that your code adheres to the project's existing coding style and structure. Most new features are likely to be derivatives of existing features, so many of the existing ViewModifiers and Bindings should be reused.

Upcoming features:

  • Support multiple Modals per screen
  • Add showModule support, for navigating between parent-level RouterView's
  • Support VisionOS