SwiftUI empowers developers with powerful APIs to design user interfaces, making it easier than ever to create visually stunning screens that enhance the user experience (UX). Once these screens are crafted, the next step is to seamlessly connect them using navigation tools like NavigationView
or NavigationStack
.
But here’s the catch: navigation logic can quickly spiral into a tangled mess as your app scales with more features and screens. The challenge lies in writing a clean, maintainable routing layer that keeps your codebase organized and easy to extend.
To tackle this, adopting a dedicated routing layer is essential, especially when working with structured app architectures like VIPER or MVVM. In VIPER, routing plays a pivotal role as the "R" in the acronym. In MVVM, however, routing often takes a backseat in SwiftUI projects. This article introduces a simple yet effective approach to integrate a routing layer within SwiftUI, ensuring your app’s navigation logic remains elegant and scalable.
Let's build a routing layer for a simple Recipe App. It has:
- A Recipe List page.
- A Recipe Detail page that displays details based on the selected recipe's ID.
When you tap on a recipe in the list, it should navigate to the detail page.
Step 1: Define a Routing Protocol
We'll start by defining a reusable protocol for routing:
protocol Routing {
associatedtype Route
associatedtype View: SwiftUI.View
@ViewBuilder
func view(for route: Route) -> Self.View
}
This protocol requires:
- A
Route
type to represent navigation destinations. - A
view(for:)
function to generate a SwiftUIView
for a given route.
Step 2: Implement the Router
Create a RecipeRouter
class that conforms to the Routing
protocol. We'll define an enum, RecipeRoute
, for the navigation destinations:
class RecipeRouter: Routing {
enum RecipeRoute: Hashable, Equatable {
case recipeHome
case recipeDetail(recipeID: String)
}
@ViewBuilder
func view(for route: RecipeRoute) -> some View {
switch route {
case .recipeHome:
RecipeListView()
case .recipeDetail(let recipeID):
RecipeDetailView(recipeID: recipeID)
}
}
}
Most importantly note that we have focused on RecipeRouter in this point. Let's say the above example app has Authentication feature which includes Login, SignUp, Forgot password or Change Password screens. All these will be part of separate router. For example we can define a AuthRouter to include all routing within authentication related pages.
Step 3: Enable Navigation State Tracking
To manage navigation dynamically, we can leverage NavigationPath
along with the @Published
property wrapper. This allows tracking route changes in a reactive way:
class RecipeRouter: ObservableObject, Routing {
enum RecipeRoute: Hashable, Equatable {
case recipeHome
case recipeDetail(recipeID: String)
}
@ViewBuilder
func view(for route: RecipeRoute) -> some View {
switch route {
case .recipeHome:
RecipeListView()
case .recipeDetail(let recipeID):
RecipeDetailView(recipeID: recipeID)
}
}
@Published var path: NavigationPath = NavigationPath()
func navigateTo(_ route: RecipeRoute) {
path.append(route)
}
func navigateBack() {
path.removeLast()
}
func popToRoot() {
path.removeLast(path.count)
}
}
Step 4: Create a Base Router View
To simplify the setup of NavigationStack
and its routing state, we wrap everything in a base view:
struct BaseRecipeRouterView<Content: View>: View {
@StateObject var router: RecipeRouter = RecipeRouter()
private let content: Content
init(@ViewBuilder content: @escaping () -> Content) {
self.content = content()
}
var body: some View {
NavigationStack(path: $router.path) {
content
.navigationDestination(for: RecipeRouter.RecipeRoute.self) { route in
router.view(for: route)
}
}
.environmentObject(router)
}
}
You can now use the BaseRecipeRouterView
as the entry point for your app:
BaseRecipeRouterView {
RecipeListView()
}
Step 5: Use Router in Views
Inject the router into views using the @EnvironmentObject
property wrapper:
struct RecipeListView: View {
@EnvironmentObject var router: RecipeRouter
var body: some View {
List {
// Example items
Button("Open Recipe 1") {
router.navigateTo(.recipeDetail(recipeID: "1"))
}
Button("Open Recipe 2") {
router.navigateTo(.recipeDetail(recipeID: "2"))
}
}
}
}
On user interaction, you can navigate to different routes easily:
router.navigateTo(.recipeDetail(recipeID: "123"))
Summary
By maintaining a separate routing layer, we:
- Simplify navigation logic.
- Make the app architecture cleaner and more modular.
- Ensure scalability as new features and screens are added.
This approach aligns well with patterns like MVVM and VIPER, allowing for a seamless user experience with a structured routing mechanism.
Feel free to adapt this pattern to your app's specific needs. Happy coding! 🚀