Skip to main content

Building an elegant Routing Layer in SwiftUI


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 SwiftUI View 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! 🚀

Popular posts from this blog

Animating label text update - choosing a better way

Recently I published a countdown app .  At one point of development - I have to show a timer on a UILabel which ticks on each seconds. As usual I started  setting text to a label object - self .timerLabel.text = someString Easy piece of cake right !?   But wait ... it won't take much user attention when timer ticks on every seconds. So I decided to make use of a simple animation while label gets text update. I found there are dozens of ways to animate a label. In this short article, I listed 3 best way you can animate text on a label. ( Spoiler Alert 👀- I decided to go with 3rd option)  1. Fade In - Fade out animation : CATransition class has got transition type `fade`. With timing function of CATransition - I was able to see the below result. let animation: CATransition = CATransition () animation.timingFunction = CAMediaTimingFunction (name: CAMediaTimingFunctionName .easeInEaseOut) animation.type = CATransitionType .fade animation.subtype = C...

Prevent Navigationbar or Tabbar overlapping Subview - solved for Card view

Recently, I started with a Card view added as a subview of UIView in a view-controller. When a view controller created along subviews, it tends to use entire screen bounds and also slips behind Tab bar or Navigation bar. In my current situation, it's second case. Casually new iOS developers will write a patch by additional value for coordinate y and subtracting bar height from its size. A lot of them posted in SO threads too : How to prevent UINavigationBar from covering top of view? View got hidden below UINavigationBar iOS 7 Navigation Bar covers some part of view at Top So, how I got solved ? self.edgesForExtendedLayout = [] This  will avoid all subviews in a view controller get behind any bars. Read full apple  documentation on here. Full Source code below :  //Simple view controller where its view layed-out as a card. class WidgetCardViewController : UIViewController { var containerView = UIView () //MARK:- View Controller Life Cyc...

Printing Staircase Pattern : Swift coding challenge

In this post, we can try interesting pattern printing challenge in Swift. The problem statement goes like this " Print a staircase of given size 'n'. Make sure that its base and height are both equal to n, and the image is drawn only using `#` symbols and spaces. The last line is not preceded by any spaces." Expected Output : # ## ### #### ##### ###### Working solution: func makePatternOf ( _ size : Int ) { var str = "" // 1 for index in ( 0 ..< size ) { let stop = size -index-1; // 2 for _ in 0 ..< stop { str. append ( " " ) ; } // 3 for _ in 0 .. . index { str. append ( "#" ) ; } print ( str ) str = "" } } makePatternOf ( 6 ) Loop to visit every row of stair case. Loop for appe...

Implementing autocompletion OTP field in iOS

Long waiting is over. !!  iOS 12 brings Autofill for OTP text field which is close to Android provided a decade back. Previously in iOS we used to toggle between OTP text screen and message inbox.  Which was hard to remember and time consuming resulting a bad user experience. Personally, I have been asked from the client/customer couple of times to implement autocompletion for OTP field and took me a lot of time to convey that it is not possible in iOS. Why Autofill was not possible previously?  We all know that Apple gives at most care for user privacy. When we see iOS architecture, each individual app is like a separate island. There is no inter-app bridge between apps (exception for Keychain and URLSchemes APIs which gives very limited scope). Thus we cannot read message content from inbox. Where to start Autofilling? First of all, the target SMS need to have the OTP Code with prefix string "Code" or "Passcode"on its message content. Beware of OTP c...

UICollectionViewCell shows with wrong size on First time - Solved

We commonly use Collection view where its cell size calculated run time. The flow layout delegate is responsible to return individual cell sizes. BUT in most of the cases, delegate method `collectionView: layout sizeForItem:` expects cell size too early. Before generating actual cell size. extension YourViewController : UICollectionViewDelegateFlowLayout { func collectionView ( _ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { return CGSize (width: externalWidth, height: externalHeight) } } For instance, if a cell size depends on external view and its frame is not yet ready - results with wrong (or outdated) cell size. Typically happens for the first time view controller laid out all views. You can find similar queries in StackOverflow community : Collection view sizeForItemNotWorking UICollectionViewCell content wrong size on first load How to refresh UICollec...