SwiftUI Coordinator. Part 3: Router


Greetings, traveler!

Today, we will discuss one of the essential participants in this SwiftUI Coordinator story — the Router. It is responsible for presenting the modules and storing the navigation path. It also stores an active item for presenting modal views. In the previous lessons, we created the basis of our application and its first modules.

Modal presentation

Let’s start with the modal presentation. This is a controversial topic in SwiftUI. Apparently, Apple suggests that we give responsibility for presenting sheets to concrete views, and we probably need to come to terms with that. However, many developers still would like to present such views centrally. Since presenting a view inside another view is a well-known option, in this tutorial, we will look at a way to do this through a router.

To show the view modally using the sheet function, we need a property that conforms to the Identifiable protocol. To do this, we can create an AnyIdentifiable class that conforms to the Identifiable protocol. It will also have a destination property of the generic type, any Identifiable. To initialize this class, we must provide a value for this property inside the initializer parameters.

class AnyIdentifiable: Identifiable {

    public let destination: any Identifiable
    
    public init(destination: any Identifiable) {
        self.destination = destination
    }
    
}

Now we can create our Router. It will be an Observable class. For the convenience of interaction, we must create two properties associated with this class. The first property, presentedSheetItem, will be private. The second property will be public. This will be a Binding property with the name sheetItem. It will work in conjunction with the first property while providing data to present modal views. 

@Observable
final class Router {
    
   var sheetItem: Binding<AnyIdentifiable?> {
        Binding(
            get: { self.presentedSheetItem },
            set: { self.presentedSheetItem = $0 }
        )
    }
    
    private var presentedSheetItem: AnyIdentifiable?
    
}

To start the presentation process, we need to create a function with one generic parameter of the Identifiable type. In the function’s body, we will assign the value of this parameter to the router property presentedSheetItem.

Accordingly, to close the modal view, we can create a function that assigns the nil value to the presentedSheetItem property. 

@Observable
final class Router {
    
   var sheetItem: Binding<AnyIdentifiable?> {
        Binding(
            get: { self.presentedSheetItem },
            set: { self.presentedSheetItem = $0 }
        )
    }
    
    private var presentedSheetItem: AnyIdentifiable?
    
    
    func presentSheet(destination: any Identifiable) {
        presentedSheetItem = AnyIdentifiable(destination: destination)
    }
    
    func dismissSheet() {
        presentedSheetItem = nil
    }
    
}

Let’s immediately create a model for the view’s modal presentation. We are creating an enum with two cases, which is how many options we have planned for our PostList feature. A little later, we will look at an example of using this model.

enum SheetDestination: Identifiable {
    var id: String {
        switch self {
        case .menu: "menu"
        case .share: "share"
        }
    }
    
    case menu(model: String)
    case share(model: String)
}

Navigation Stack

We are done with presenting modal views, so let’s start with the navigation path. We can create a navigationPath property with the NavigationPath type to control the navigation stack.

Next, we can create functions to control the NavigationPath from the outside. 

@Observable
final class Router {
    
    var navigationPath = NavigationPath()
    var sheetItem: Binding<AnyIdentifiable?> {
        Binding(
            get: { self.presentedSheetItem },
            set: { self.presentedSheetItem = $0 }
        )
    }
    
    private var presentedSheetItem: AnyIdentifiable?
        
    func presentSheet(destination: any Identifiable) {
        presentedSheetItem = AnyIdentifiable(destination: destination)
    }
    
    func dismissSheet() {
        presentedSheetItem = nil
    }
    
    func navigate(to destination: any Hashable) {
        navigationPath.append(destination)
    }
    
    func navigateBack() {
        navigationPath.removeLast()
    }
    
    func navigateToRoot() {
        navigationPath.removeLast(navigationPath.count)
    }
    
}

As you can see, the first function that allows you to navigate to a specific view takes the destination value with the Hashable generic type. Let’s create one.

enum Destination: Hashable {
    case postDetails(model: String)
}

Coordinator

As you may remember, in one of the previous lessons, we created the PostListTabCoordinator. Now, it’s time to get back to it. As you guessed, we must create a state property with the Router type. We need to leave this router as an environment for all child views. For NavigationStack, we can now use the appropriate router property.

struct PostListTabCoordinator: View {
    
    @State var router: Router = .init()
    
    var body: some View {
        NavigationStack(path: $router.navigationPath) {
            PostListCoordinator()
        }
        .environment(router)
    }
    
}

Next, we move on to the PostListCoordinator. Here, we create an Environment property with the Router type, the value of which we get from the parent PostListTabCoordinator.

struct PostListCoordinator: View {

    @Environment(Router.self) private var router
    
    var body: some View {
        PostListView(viewModel: PostListViewModel())
    }
    
}

Now let’s create a navigation destination modifier for our PostListView. We will use the previously created enum to navigate to the PostDetailsView.

struct PostListCoordinator: View {

    @Environment(Router.self) private var router
        
    var body: some View {
        PostListView(viewModel: PostListViewModel())
            .navigationDestination(for: Destination.self) { destination in
                switch destination {
                case .postDetails(let model):
                    PostDetailsView(viewModel: PostDetailViewModel(content: model))
                }
            }
    }
    
}

In the same way, we can organize a modal presentation of the views. However, due to the features of the tools available, we must cast the value of AnyIdentifiable to the enum SheetDestination we need. Then, we can use this enum to switch between cases.

struct PostListCoordinator: View {
    
    @Environment(Router.self) private var router
    
    var body: some View {
        PostListView(viewModel: PostListViewModel())
            .navigationDestination(for: Destination.self) { destination in
                switch destination {
                case .postDetails(let model):
                    PostDetailsView(viewModel: PostDetailViewModel(content: model))
                }
            }
            .sheet(item: router.sheetItem) { destination in
                if let destination = destination.destination as? SheetDestination {
                    switch destination {
                    case .menu(let model):
                        Text(model)
                            .padding()
                            .background(.purple)
                            .foregroundStyle(.white)
                            .font(.headline)
                            .onTapGesture {
                                router.dismissSheet()
                            }
                    case .share(let model):
                        Text(model)
                            .padding()
                            .background(.green)
                            .foregroundStyle(.white)
                            .font(.headline)
                            .onTapGesture {
                                router.dismissSheet()
                            }
                    }
                }
            }
    }
    
}

Great! Now, all we have to do is throw the router into all child views and call it to initialize the navigation process after certain actions. Let’s do it.

struct PostListView: View {
    
    @Environment(Router.self) private var router
    @State private var viewModel: PostListViewModelObservable
    
    init(viewModel: PostListViewModelObservable) {
        self.viewModel = viewModel
    }
    
    var body: some View {
        switch viewModel.state {
        case .loading:
            ProgressView()
            
        case let .display(postList):
            ScrollView {
                LazyVStack {
                    ForEach(postList, id: \.self) { post in
                        VStack {
                            Text(post.text)
                                .padding()
                                .font(.headline)
                            
                            Divider()
                                .padding()
                        }
                        .onTapGesture {
                            router.navigate(to: Destination.postDetails(model: post.text))
                        }
                    }
                }
            }
            .toolbar {
                ToolbarItem(placement: .topBarLeading) {
                    Button {
                        router.presentSheet(destination: SheetDestination.menu(model: "Menu"))
                    } label: {
                        Image(systemName: "line.3.horizontal")
                    }
                }
            }
            
        case let .error(error):
            Text(error.localizedDescription)
        }
    }
}
struct PostDetailsView: View {
    
    @Environment(Router.self) private var router
    @State private var viewModel: PostDetailViewModelObservable
    
    init(viewModel: PostDetailViewModelObservable) {
        self.viewModel = viewModel
    }
    
    var body: some View {
        ScrollView {
            Text(viewModel.content)
                .padding()
        }
        .toolbar {
            ToolbarItem(placement: .topBarTrailing) {
                Button {
                    router.presentSheet(destination: SheetDestination.share(model: "Share"))
                } label: {
                    Image(systemName: "square.and.arrow.up")
                }
            }
        }
    }
    
}

Conclusion

We now have a fully developed project using the MVVM + Coordinators architecture. The project is designed to expand easily with flexible, versatile solutions that ensure consistent navigation. Sharing responsibilities between modules helps avoid creating overloaded objects and confusion in business logic. I hope you found this series helpful and enjoyable. If you need to access the source code, it is available on GitHub.

See you soon!