SwiftfulRouting is a native, declarative framework that enables programmatic navigation in SwiftUI applications.
Sample project: https://github.com/SwiftfulThinking/SwiftfulRoutingExample
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
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.
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
toFALSE
. - 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 {
}
}
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")
}
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.
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
}
}
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")
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()
}
}
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.
- Open an issue for issues with the existing codebase.
- Open a discussion for new feature requests.
- Submit a pull request when the feature is ready.
Upcoming features:
- Support multiple Modals per screen
- Add
showModule
support, for navigating between parent-level RouterView's - Support VisionOS