A navigation library that decouples Navigation logic in SwiftUI from the View and allows you to dynamically navigate to other Views programatically in your app.
- Uses native SwiftUI navigation
- No custom 3rd party navigation
- Retains Navigation back button
- Retains swipe-to-dismiss
- Light weight, no wrapper views
- Navigation Stack: Stores the state of SwiftUI navigation for your app
- Dynamic Navigation: Full programatic navigation at runtime. No static hardcoded view destinations necessary
- Simple navigation APIs out of the box:
navigate(to:)
pop()
popToRoot()
struct DetailScreen: ScreenView {
@EnvironmentObject var navigator: Navigator<ScreenID, AppViewFactory>
@State var showNextScreen: Bool = false
var screenId: ScreenID
var body: some View {
Button("Next") {
navigator.navigate(to: anotherScreen())
}
.navigationTitle("Detail Screen")
.bindNavigation(self, binding: $showNextScreen)
}
}
Create an enum that that maps to the screens in your app
along with any needed values. Ensure that it conforms
to Hashable
enum ScreenID: Hashable {
case rootScreen
case detailScreen(detailID: String)
}
Then create a class that conforms to ViewFactory
and
implement makeView(screenType:)
so that the given ScreenID
enum returns the associated View
.
class AppViewFactory: ViewFactory {
@ViewBuilder
func makeView(screenType: ScreenWrapper<ScreenID>) -> some View {
switch screenType {
case .screenWrapper(let screenId):
switch screenId {
case .rootScreen:
RootScreen()
case .detailScreen:
DetailScreen(screenId: screenId!)
case .none:
// EmptyView is fine in the exhaustive case
// as this is just a placeholder until the
// Navigation state is instantiated. The
// app will never need to navigate to this
EmptyView()
}
}
}
}
In the @main
app delcaration, where your root view
is the top level view in the navigation stack (or wherever
you'd like to place a NavigationView
with programatic navigation)
initialize the NavigationView
using the with(_:)
method to inject
your instance of Navigator
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
NavigationView.with(Navigator(rootScreen: ScreenID.rootScreen, viewFactory: AppViewFactory()) {
RootScreen()
}
}
}
}
For each top level view, conform them to ScreenView
and apply the bindNavigation(_:binding:)
view modifier
struct RootScreen: ScreenView {
@EnvironmentObject var navigator: Navigator<ScreenID, AppViewFactory>
@State var showNextScreen: Bool = false
var screenId = .rootScreen
var body: some View {
List {
Button("Navigate to Deatil Screen") {
// TODO
}
}
.navigationTitle("Root Screen")
.bindNavigation(self, binding: $showNextScreen)
}
}
struct DetailScreen: ScreenView {
@EnvironmentObject var navigator: Navigator<ScreenID, AppViewFactory>
@State var showNextScreen: Bool = false
var screenId: ScreenID
var body: some View {
VStack {
Text("Here is some amazing detail.")
Button("Go Back") {
// TODO
}
.tint(.red)
.buttonStyle(.borderedProminent)
.buttonBorderShape(.automatic)
.controlSize(.large)
}
.navigationTitle("Detail Screen")
.bindNavigation(self, binding: $showNextScreen)
}
}
Note that except for the RootScreen
all other screens
should have an id
in their ScreenID
enum value in order
to differentiate same screens in the navigation stack. This
can be in the form of a business logic id detailID
or some
deafult id that has no business logic but merely used to id
the screen
enum ScreenID: Hashable {
// ...
case anotherScreen(id: UUID = UUID())
}
Now that all the setup is done, we can use the Navigator
instance in the ScreenView
View to execute system level
View navigation
struct RootScreen: ScreenView {
@EnvironmentObject var navigator: Navigator<ScreenID, AppViewFactory>
@State var showNextScreen: Bool = false
var screenId = .rootScreen
var body: some View {
List {
Button("Navigate to Deatil Screen") {
// Use navigate(to:) to navigate to another ScreenView
// This can be anywhere at the view level
// This can also be called at an abstraction layer like a ViewModel
navigator.navigate(to: ScreenID.detailScreen(detailID: "detail-123"))
}
}
.navigationTitle("Root Screen")
.bindNavigation(self, binding: $showNextScreen)
}
}
struct DetailScreen: ScreenView {
@EnvironmentObject var navigator: Navigator<ScreenID, AppViewFactory>
@State var showNextScreen: Bool = false
var screenId: ScreenID
var body: some View {
VStack {
Text("Here is some amazing detail.")
Button("Go Back") {
// Use pop() to navigate back to the
// previous view by popping the current
navigator.pop()
}
.tint(.red)
.buttonStyle(.borderedProminent)
.buttonBorderShape(.automatic)
.controlSize(.large)
}
.navigationTitle("Detail Screen")
.bindNavigation(self, binding: $showNextScreen)
}
}
As stated previously, the Navigator
class has the property navStack
which keeps
a stack (OrderedDictionary) of the ScreenView
associated ScreenID
enum values that are
currently active in the NavigationView. Clients can use it to execte custom navigation logic.
/// Holds the ordered uniqe set of screens that makes up the Navigation State of the client app,
/// along with its associated Boolean subject, which toggles the NavigationLink.isActive
var navStack: OrderedDictionary<ScreenIdentifer, CurrentValueSubject<Bool, Never>> { get }
As an example, we can use it create an extention funtion on Navigator
called popToDetailWithSpecificIdOrRoot(id: String)
extension Navigator where ScreenIdentifer == ScreenID {
func popToDetailWithSpecificIdOrRoot(id: String) {
// Since all screens that have been pushed onto the NavigationView
// are stored in the navStack as keys, we can simply search through
// them for the specific detail screen with the called id
if let detailScreen: ScreenID = navStack.keys.elements.first(where: {
if case ScreenID.detailScreen(let detailID) = $0 {
return detailID == id
}
return false
}) {
// Using the first(where:) collections function
// if we find the detail screen with the called id
// then the navStack Dictionary can be keyed
// on the found detailScreen to return the Combine subject
// that controls the NavigationLink.isActive binding
// for that detailScreen
let detailScreenIsActiveBinding = navStack[detailScreen]!
// Sending the false flag to the NavigationLink.isActive binding
// will dismiss all views off the NavigationView stack to reveal
// the detailScreen
detailScreenIsActiveBinding.send(false)
} else {
// if the detail screen is not found in the current stack
// then we default to pop all views off the stack to reveal the
// rootScreen view
let rootScreenNavigationIsActiveBinding = navStack.elements[0]
rootScreenNavigationIsActiveBinding.value.send(false)
// could also call self.popToRoot()
}
}
}
Because the Navigator
class can be a singleton not copuled to any specific view, we can call
popToDetailWithSpecificIdOrRoot(id:)
anywhere in the app.
@main
struct MyApp: App {
// Keep reference to the navigator, either as a local property or in an abstraction
// layer, such as an AppCoordinator
let navigator = Navigator(rootScreen: ScreenID.rootScreen, viewFactory: AppViewFactory())
var body: some Scene {
WindowGroup {
NavigationView.with(navigator) {
RootScreen()
}
.task {
// mimic a system event, such as a notification
// create an artificial delay of 10 seconds after the app starts up
try! await Task.sleep(nanoseconds: 10_000_000_000)
// User navigates to different views, adding to the navStack
// However far along the user is, this will pop to the first
// detailScreen with called id, or pop to the root screen
navigator.popToDetailWithSpecificIdOrRoot(id: "detail-123")
}
}
}
}
Big thanks to Obsured Pixels' excellent article, which inspired this library.