🚀 Learn how to build and use this package: https://www.swiftful-thinking.com/offers/REyNLwwH
- ✅ Segues
- ✅ Alerts
- ✅ Modals
- ✅ Transitions
- ✅ Modules
- 1️⃣ Read the docs below
- 2️⃣ Watch YouTube Tutorial
- 3️⃣ Practice with Sample Project
- 4️⃣ Test the Starter Project
- ➡️ iOS 17+ use version 6.0 or above
- ➡️ iOS 14+ use version 5.3.6
- ➡️ iOS 13+ use version 2.0.2
Details (Click to expand)
Use a RouterView
to replace NavigationStack
in your SwiftUI code.
Before SwiftfulRouting:
NavigationStack {
MyView()
.navigationDestination()
.sheet()
.fullScreenCover()
.alert()
}
With SwiftfulRouting:
RouterView { _ in
MyView()
}
Use a router
to perform actions.
struct MyView: View {
@Environment(\.router) var router
var body: some View {
Text("Hello, world!")
.onTapGesture {
router.showScreen { _ in
AnotherView()
}
}
}
}
All available methods in router
are in AnyRouter.swift.
Examples:
router.showScreen()
router.showAlert()
router.showModal()
router.showTransition()
router.showModule()
router.dismissScreen()
router.dismissAlert()
router.dismissModal()
router.dismissTransition()
router.dismissModule()
Details (Click to expand)
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. This allows declarative code to behave as programmatic code, since the view modifiers are connected in advance. Screen destinations are erased to generic types, allowing the developer to determine the destination at the time of execution.
Version 6.0 adds many new features to the framework by implementing an internal RouterViewModel across the screen heirarchy that allows and screen's router to perform actions that affect the entire heirarchy. The solution introduces [AnyDestinationStack] which is a single array that holds bindings for all active segues in the heirarchy.
// Example of what an [AnyDestinationStack] might look like:
[
[.fullScreenCover]
[.push, .push, .push, .push]
[.sheet]
[]
]
In addition to adding a router
to the Environment, every segue immedaitely returns a router
in the View's closure. This allows the developer to have access to the screen's routing methods before the screen is created. Leave fully decouples routing logic from the View layer and is perfect for more complex app architectures, such as MVVM or VIPER.
RouterView { router in
MyView(router: router)
}
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 NavigationStack and add modifiers to support all potential segues. This would replace an existing NavigationStack
in your code.
Use a RouterView
to replace NavigationStack
in your SwiftUI code.
// Before SwiftfulRouting
NavigationStack {
MyView()
.navigationDestination()
.sheet()
.fullScreenCover()
.alert()
}
// With SwiftfulRouting
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 can also pass the router
directly into the child views.
RouterView { router in
MyView(router: router)
}
You can also use the returned router
directly. A new router
is created and added to the view heirarchy after each segue and are therefore unique to each screen. In the below example, the tap gesture on "View3" could call dismissScreen()
from router2
or router3
, which would have different behaviors. This is done on purpose and is further explained in the docs below!
RouterView { router1 in
Text("View 1")
.onTapGesture {
router1.showScreen(.push) { router2 in
Text("View 2")
.onTapGesture {
router2.showScreen(.push) { router3 in
Text("View3")
.onTapGesture {
router3.dismissScreen() // Dismiss View3
router2.dismissScreen() // Dismiss View2 and View 3
}
}
}
}
}
}
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
, which will add a NavigationStack
by default.
Most apps should replace their existing NavigationStack
with a RouterView
, however, if you cannot remove it, you can add a RouterView
but initialize it without a NavigationStack
.
The framework uses the native SwiftUI navigation bar, so all related modifiers will still work.
RouterView(addNavigationView: false) { router in
MyView()
.navigationBarHidden(true)
.toolbar {
}
}
Details (Click to expand)
Router supports all native SwiftUI segues.
// Navigation destination
router.showScreen(.push) { _ in
Text("View2")
}
// Sheet
router.showScreen(.sheet) { _ in
Text("View2")
}
// FullScreenCover
router.showScreen(.fullScreenCover) { _ in
Text("View2")
}
Segue methods also accept AnyDestination
as a convenience.
let screen = AnyDestination(segue: .push, destination: { router in
Text("Hello, world!")
})
router.showScreen(screen)
Segue to multiple screens at once. This will immediately trigger each screen in order, ending with the last screen displayed.
let screen1 = AnyDestination(segue: .push, destination: { router in
Text("Hello, world!")
})
let screen2 = AnyDestination(segue: .sheet, destination: { router in
Text("Another screen!")
})
let screen3 = AnyDestination(segue: .push, destination: { router in
Text("Third screen!")
})
router.showScreens(destinations: [screen1, screen2, screen3])
Use .sheetConfig()
or .fullScreenCoverConfig()
to for resizable sheets and backgrounds in new Environments.
let config = ResizableSheetConfig(
detents: [.medium, .large],
dragIndicator: .visible
)
router.showScreen(.sheetConfig(config: config)) { _ in
Text("Screen2")
}
let config = FullScreenCoverConfig(
background: .clear
)
router.showScreen(.fullScreenCoverConfig(config: config)) { _ in
Text("Screen2")
}
All segues have an onDismiss
method.
router.showScreen(.push, onDismiss: {
// dismiss action
}, destination: { _ in
Text("Hello, world!")
})
Fully customize each segue!
let screen = AnyDestination(
id: "profile_screen", // id of screen (used for analytics)
segue: .fullScreenCover, // segue option
location: .insert, // where to add screen within the view heirarchy
animates: true, // animate the segue
transitionBehavior: .keepPrevious, // transition behavior (only relevant for showTransition methods)
onDismiss: {
// Do something when screen dismisses
},
destination: { _ in
Text("ProfileView")
}
)
Additional convenience methods:
router.showSafari {
URL(string: "https://www.apple.com")
}
Details (Click to expand)
Dismiss one screen.
router.dismissScreen()
You can also use the native SwiftUI method.
@Environment(\.dismiss) var dismiss
Dismiss screen at id.
router.dismissScreen(id: "x")
Dismiss screens back to, but not including, id.
router.dismissScreen(upToScreenId: "x")
Dismiss a specific number of screens.
router.dismissScreens(count: 2)
Dismiss all .push segues on the NavigationStack of the current screen.
router.dismissPushStack()
Dismiss screen environment (ie. the closest .sheet or .fullScreenCover to this screen).
router.dismissEnvironment()
Dismiss the last screen in the screen heirarchy.
router.dismissLastScreen()
Dismiss the last push stack in the screen heirarchy.
router.dismissLastPushStack()
Dismiss the last environment in the screen heirarchy.
router.dismissLastEnvironment()
Dismiss all screens in the screen heirarchy.
router.dismissLastEnvironment()
Details (Click to expand)
Add screens to a queue to navigate to them later!
router.addScreenToQueue(destination: screen1)
router.addScreensToQueue(destinations: [screen1, screen2, screen3])
Trigger segue to the first screen in queue, if available.
// Show next screen if available
router.showNextScreen()
// show next screen, otherwise, throw error
do {
try router.tryShowNextScreen()
} catch {
// Do something else
}
Remove screens from the queue.
router.removeScreenFromQueue(id: "x")
router.removeScreensFromQueue(ids: ["x", "y"])
router.removeAllScreensFromQueue()
For example, an onboarding flow might have a variable number of screens depending on the user's responses. As the user progresses, add screens to the queue and then the logic within each screen is "try to go to next screen (if available) otherwise dismiss onboarding"
Additional convenience methods:
// Segue to a the next screen in the queue (if available) otherwise dismiss the screen.
router.showNextScreenOrDismissScreen()
// Segue to a the next screen in the queue (if available) otherwise dismiss environment.
router.showNextScreenOrDismissEnvironment()
// Segue to a the next screen in the queue (if available) otherwise dismiss push stack.
router.showNextScreenOrDismissPushStack()
Details (Click to expand)
Router supports all 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") {
}
}
Buttons closure supports all the same features as the native SwiftUI closure, such as TextFields.
let alert = AnyAlert(style: .alert, title: "Title goes here", subtitle: "Subtitle goes here", buttons: {
TextField("Enter your name", text: $textfieldText)
Button("SUBMIT", action: {
})
})
Alert methods also accept AnyAlert
as a convenience.
let alert = AnyAlert(
style: .alert,
location: .currentScreen,
title: "Title",
subtitle: nil
)
router.showAlert(alert: alert)
Dismiss the alert.
router.dismissAlert()
router.dismissAllAlerts()
Additional convenience methods.
router.showBasicAlert(text: "Error")
Details (Click to expand)
Modals appear on top of the current screen. Router supports an infinite number of simultaneous modals.
router.showModal {
MyModal()
.frame(width: 300, height: 300)
}
Fully customize modal's display.
router.showModal(
id: "modal_1", // Id for modal
transition: .move(edge: .bottom), // AnyTransition
animation: .smooth, // transition animation
alignment: .center, // Alignment within screen
backgroundColor: Color.black.opacity(0.1), // Color behind modal
backgroundEffect: BackgroundEffect(effect: UIBlurEffect(style: .systemMaterialDark), intensity: 0.1), // Blur effect behind modal
dismissOnBackgroundTap: true, // Add dismiss tap gesture on background layer
ignoreSafeArea: true, // Modal will safe area
onDismiss: {
// Do something when modal is dismissed
},
destination: {
MyModal()
}
)
Modal methods also accept AnyModal
as a convenience.
let modal = AnyModal {
MyModal()
}
router.showModal(modal: modal)
Trigger multiple modals at the same time.
router.showModals(modals: [modal1, modal2])
Dismiss the last modal displayed.
router.dismissModal()
Dismiss modal by id.
router.dismissModal(id: "modal_1")
Dismiss modals above, but not including, id.
router.dismissModals(upToModalId: "modal_1")
Dismiss specific number of modals.
router.dismissModals(count: 2)
Dismiss all modals.
router.dismissAllModals()
Additional convenience methods:
router.showBasicModal {
Rectangle()
.frame(width: 200, height: 200)
}
router.showBottomModal {
Rectangle()
.frame(width: 200, height: 200)
}
Details (Click to expand)
Transitions change the current screen WITHOUT performing a full segue.
Transitions are NOT segues!
Transitions are similar to using an "if-else" statement to switch between views.
router.showTransition { router in
MyView()
}
Important: When showing a new screen via showScreen
there is a parameter transitionBehavior
. This will determine the UI behavior of any showTransition
on the resulting screen.
Set transitionBehavior
to .keepPrevious
to keep previous screens in memory. This will transition new screens ON TOP of each other.
Set transitionBehavior
to .removePrevious
to remove previous screens from memory. This will transition a new screen on, while transitioning the old screen off.
router.showScreen(transitionBehavior: .removePrevious) { _ in
MyView()
}
Transition methods also accept AnyTransitionDestination
as a convenience.
let screen = AnyTransitionDestination { _ in
MyView()
}
router.showTransition(transition: screen)
Add multiple transitions on the screen and display the last one on top.
router.showTransitions(transitions: [screen1, screen2, screen3])
Fully customize transition's display.
let transition = AnyTransitionDestination(
id: "transition_1", // Id for the screen
transition: .trailing, // Transition edge
allowsSwipeBack: true, // Add a swipe back gesture to the screen's edge
onDismiss: {
// Do something when transition dismisses
},
destination: { router in
MyView()
}
)
Dismiss the last transition displayed.
router.dismissTransition()
Dismiss transition by id.
router.dismissTransition(id: "transition_1")
Dismiss transitions above, but not including, id.
router.dismissTransitions(upToId: "transition_1")
Dismiss specific number of transitions.
router.dismissTransitions(count: 2)
Dismiss all transitions.
router.dismissAllTransitions()
Additional convenience methods:
// Dismiss transition (if there is one) otherwise dismiss screen.
router.dismissTransitionOrDismissScreen()
Details (Click to expand)
Add transitions to a queue to trigger them later!
router.addTransitionToQueue(transition: screen1)
router.addTransitionsToQueue(transitions: [screen1, screen2, screen3])
Trigger transition to the first in queue, if available.
// Show next transition if available
router.showNextTransition()
// show next transition, otherwise, throw error
do {
try router.tryShowNextTransition()
} catch {
// Do something else
}
Remove transitinos from the queue.
router.removeTransitionFromQueue(id: "x")
router.removeTransitionsFromQueue(ids: ["x", "y"])
router.removeAllTransitionsFromQueue()
For example, an onboarding flow might have a variable number of screens depending on the user's responses. As the user progresses, add screens to the queue and then the logic within each screen is "try to go to next screen (if available) otherwise dismiss onboarding"
Additional convenience methods:
// Trigger next transition or trigger next screen or dismiss screen.
router.showNextTransitionOrNextScreenOrDismissScreen()
Details (Click to expand)
Modules swap the ENTIRE view heirarchy and replace the existing RouterView
with a new one.
router.showModule { router in
MyView()
}
Important: Module support is NOT automatically included within RouterView
. You must enable it by setting addModuleSupport
to true
. This is done on purpose, in case there are multiple RouterView
in the same heirarchy.
router.showScreen(addModuleSupport: true) { _ in
MyView()
}
Depending on how deep your view heirarchy is, you may want to dismiss screens before switching modules for better UX.
Task {
router.dismissAllScreens()
try? await Task.sleep(for: .seconds(1))
router.showModule { router in
MyView()
}
}
Module methods also accept AnyTransitionDestination
as a convenience.
let screen = AnyTransitionDestination { _ in
MyView()
}
router.showModule(module: screen)
The user's last module is saved in UserDefaults and can be used to restore the app's state across sessions.
@State private var lastModuleId = UserDefaults.lastModuleId
var body: some Scene {
WindowGroup {
if lastModuleId == "onboarding" {
RouterView(id: "onboarding", addModuleSupport: true) { router in
OnboardingView()
}
} else {
RouterView(id: "home", addModuleSupport: true) { router in
HomeView()
}
}
}
}
Add multiple modules to the heirarchy and display the last one.
router.showModules(modules: [module1, module2, module3])
Fully customize module's display.
let module = AnyTransitionDestination(
id: "module_1", // Id for the screen
transition: .trailing, // Transition edge
allowsSwipeBack: true, // Add a swipe back gesture to the screen's edge
onDismiss: {
// Do something when transition dismisses
},
destination: { router in
MyView()
}
)
Note: You can dismiss modules, although it is easier to use showModule
to display the previous module again.
Dismiss the last module displayed.
router.dismissModule()
Dismiss module by id.
router.dismissModule(id: "module_1")
Dismiss modules above, but not including, id.
router.dismissModules(upToId: "module_1")
Dismiss specific number of modules.
router.dismissModules(count: 2)
Dismiss all modules.
router.dismissAllModules()
Details (Click to expand)
Built-in logging that can be used for debugging and analytics.
// Set log level using internal logger:
SwiftfulRoutingLogger.enableLogging(level: .analytic, printParameters: true)
Add your own implementation to handle unique events in your app.
struct MyLogger: RoutingLogger {
func trackEvent(event: any RoutingLogEvent) {
let name = event.eventName
let params = event.parameters
switch event.type {
case .info:
break
case .analytic:
break
case .warning:
break
case .severe:
break
}
}
}
SwiftfulRoutingLogger.enableLogging(logger: MyLogger())
Or use SwiftfulLogging directly.
let logManager = LogManager(services: [
ConsoleService(printParameters: true),
FirebaseCrashlyticsService(),
MixpanelService()
])
SwiftfulRoutingLogger.enableLogging(logger: logManager)
Additional values to look into the underlying view heirarchy.
// Active screen stacks in the heirarchy
router.activeScreens
// Active screen queue
router.activeScreenQueue
// Has at least 1 screen in queue
router.hasScreenInQueue
// Active alert
router.activeAlert
// Has alert displayed
router.hasActiveAlert
// Active modals on screen
router.activeModals
// Has at least 1 modal displayed
router.hasActiveModal
// Active transitions on screen
router.activeTransitions
// Has at least 1 active transtion
router.hasActiveTransition
// Active transition queue
router.activeTransitionQueue
// Has at least 1 transition in queue
router.hasTransitionInQueue
// Active modules
router.activeModules
Details (Click to expand)
Even without SwiftfulRouting, SwiftUI developers must decide between using 1 NavigationStack for the entire application or individual NavigationStacks for each tab.
If you use only 1 NavigationStack
, it will be a parent to the TabView
and therefore the tabbar will also push off screen after a segue.
1 NavigationStack without SwiftfulRouting:
NavigationStack {
TabView {
Text("Screen1")
.tabItem { Label("Home", systemImage: "house.fill") }
Text("Screen2")
.tabItem { Label("Search", systemImage: "magnifyingglass") }
Text("Screen3")
.tabItem { Label("Profile", systemImage: "person.fill") }
}
}
1 NavigationStack with SwiftfulRouting:
RouterView { _ in
TabView {
Text("Screen1")
.tabItem { Label("Home", systemImage: "house.fill") }
Text("Screen2")
.tabItem { Label("Search", systemImage: "magnifyingglass") }
Text("Screen3")
.tabItem { Label("Profile", systemImage: "person.fill") }
}
}
Individual NavigationStacks without SwiftfulRouting:
TabView {
NavigationStack {
Text("Screen1")
.tabItem { Label("Home", systemImage: "house.fill") }
}
NavigationStack {
Text("Screen2")
.tabItem { Label("Search", systemImage: "magnifyingglass") }
}
NavigationStack {
Text("Screen3")
.tabItem { Label("Profile", systemImage: "person.fill") }
}
}
Individual NavigationStacks with SwiftfulRouting:
TabView {
RouterView { _ in
Text("Screen1")
.tabItem { Label("Home", systemImage: "house.fill") }
}
RouterView { _ in
Text("Screen2")
.tabItem { Label("Search", systemImage: "magnifyingglass") }
}
RouterView { _ in
Text("Screen3")
.tabItem { Label("Profile", systemImage: "person.fill") }
}
}
Regardless of your choice, you may want to add a parent RouterView
to addModuleSupport
that has addNavigationStack
set to false
.
struct AppRootView: View {
var body: some View {
RouterView(addNavigationStack: false, addModuleSupport: true) { _ in
AppTabbarView()
}
}
}
struct AppTabbarView: View {
var body: some View {
TabView {
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") }
RouterView(addNavigationStack: true, addModuleSupport: false, content: { _ in
Text("Screen3")
})
.tabItem { Label("Profile", systemImage: "person.fill") }
}
}
}
Therefore, a full app implementation can look like:
struct AppRootView: View {
@State private var lastModuleId = UserDefaults.lastModuleId
@ViewBuilder
var body: some View {
if lastModuleId == "onboarding" {
RouterView(id: "onboarding", addModuleSupport: true) { router in
OnboardingView()
}
} else {
RouterView(id: "tabbar", addNavigationStack: false, addModuleSupport: true) { _ in
AppTabbarView()
}
}
}
}
struct AppTabbarView: View {
var body: some View {
TabView {
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") }
RouterView(addNavigationStack: true, addModuleSupport: false, content: { _ in
Text("Screen3")
})
.tabItem { Label("Profile", systemImage: "person.fill") }
}
}
}
Reference the Starter Project for an full implementation!
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:
- Internalize tabbar support
- Add Module queue
- Add Module tests
- Add Modal queue
- Add remove(count:) to all queues
- Add support for showing in-app web browser
- Add supprot for opening other apps (email, etc.)