'SwiftUI - how to avoid navigation hardcoded into the view?

I try to do the architecture for a bigger, production ready SwiftUI App. I am running all the time into the same problem which points to a major design flaw in SwiftUI.

Still nobody could give me a full working, production ready answer.

How to do reusable Views in SwiftUI which contain navigation?

As the SwiftUI NavigationLink is strongly bound to the view this is simply not possible in such a way that it scales also in bigger Apps. NavigationLink in those small sample Apps works, yes - but not as soon as you want to reuse many Views in one App. And maybe also reuse over module boundaries. (like: reusing View in iOS, WatchOS, etc...)

The design problem: NavigationLinks are hardcoded into the View.

NavigationLink(destination: MyCustomView(item: item))

But if the view containing this NavigationLink should be reusable I can not hardcode the destination. There has to be a mechanism which provides the destination. I asked this here and got quite a good answer, but still not the full answer:

SwiftUI MVVM Coordinator/Router/NavigationLink

The idea was to inject the Destination Links into the reusable view. Generally the idea works but unfortunately this does not scale to real Production Apps. As soon as I have multiple reusable screens I run into the logical problem that one reusable view (ViewA) needs a preconfigured view-destination (ViewB). But what if ViewB also needs a preconfigured view-destination ViewC? I would need to create ViewB already in such a way that ViewC is injected already in ViewB before I inject ViewB into ViewA. And so on.... but as the data which at that time has to be passed is not available the whole construct fails.

Another idea I had was to use the Environment as dependency injection mechanism to inject destinations for NavigationLink. But I think this should be considered more or less as a hack and not a scalable solution for large Apps. We would end up using the Environment basically for everything. But because Environment also can be used only inside View's (not in separate Coordinators or ViewModels) this would again create strange constructs in my opinion.

Like business logic (e.g. view model code) and view have to be separated also navigation and view have to be separated (e.g. the Coordinator pattern) In UIKit it's possible because we access to UIViewController and UINavigationController behind the view. UIKit's MVC already had the problem that it mashed up so many concepts that it become the fun-name "Massive-View-Controller" instead of "Model-View-Controller". Now a similar problem continues in SwiftUI but even worse in my opinion. Navigation and Views are strongly coupled and can not be decoupled. Therefore it's not possible to do reusable views if they contain navigation. It was possible to solve this in UIKit but now I can't see a sane solution in SwiftUI. Unfortunately Apple did not provide us an explanation how to solve architectural issues like that. We got just some small sample Apps.

I would love to be proven wrong. Please show me a clean App design pattern which solves this for big production ready Apps.

Thanks in advance.


Update: this bounty will end in a few minutes and unfortunately still nobody was able to provide a working example. But I will start a new bounty to solve this problem if I can't find any other solution and link it here. Thanks to all for their great Contribution!


Update 18th June 2020: I got an answer from Apple regarding this issue, proposing something like this to decouple views and models:

enum Destination {
  case viewA
  case viewB 
  case viewC
}

struct Thing: Identifiable {
  var title: String
  var destination: Destination
  // … other stuff omitted …
}

struct ContentView {
  var things: [Thing]

  var body: some View {
    List(things) {
      NavigationLink($0.title, destination: destination(for: $0))
    }
  }

  @ViewBuilder
  func destination(for thing: Thing) -> some View {
    switch thing.destination {
      case .viewA:
        return ViewA(thing)
      case .viewB:
        return ViewB(thing)
      case .viewC:
        return ViewC(thing)
    }
  }
}

My response was:

Thanks for the feedback. But as you see you still have the strong coupling in the View. Now "ContentView" needs to know all the views (ViewA, ViewB, ViewC) it can navigate too. As I said, this works in small sample Apps, but it does not scale to big production ready Apps.

Imagine that I create a custom View in a Project in GitHub. And then import this view in my App. This custom View does not know anything about the other views it can navigate too, because they are specific to my App.

I hope I explained the problem better.

The only clean solution I see to this problem is to separate Navigation and Views like in UIKit. (e.g. UINavigationController)

Thanks, Darko

So still no clean & working solution for this problem. Looking forward to WWDC 2020.


Update September 2021: Using AnyView is not a good general solution for this problem. In big Apps basically all views have to be designed in a reusable way. This would mean that AnyView get's used everywhere. I had a session with two Apple developers and they clearly explained to me the AnyView creates a way worse performance then View and it should be only used in exceptional cases. The underlying reason for this is that the type of AnyView can't be resolved during compile time so it has to be allocated on the heap.



Solution 1:[1]

The closure is all you need!

struct ItemsView<Destination: View>: View {
    let items: [Item]
    let buildDestination: (Item) -> Destination

    var body: some View {
        NavigationView {
            List(items) { item in
                NavigationLink(destination: self.buildDestination(item)) {
                    Text(item.id.uuidString)
                }
            }
        }
    }
}

I wrote a post about replacing the delegate pattern in SwiftUI with closures. https://swiftwithmajid.com/2019/11/06/the-power-of-closures-in-swiftui/

Solution 2:[2]

My idea would pretty much be a combination of Coordinator and Delegate pattern. First, create a Coordinator class:


struct Coordinator {
    let window: UIWindow

      func start() {
        var view = ContentView()
        window.rootViewController = UIHostingController(rootView: view)
        window.makeKeyAndVisible()
    }
}

Adapt the SceneDelegate to use the Coordinator :

  func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            let coordinator = Coordinator(window: window)
            coordinator.start()
        }
    }

Inside of ContentView, we have this:


struct ContentView: View {
    var delegate: ContentViewDelegate?

    var body: some View {
        NavigationView {
            List {
                NavigationLink(destination: delegate!.didSelect(Item())) {
                    Text("Destination1")
                }
            }
        }
    }
}

We can define the ContenViewDelegate protocol like this:

protocol ContentViewDelegate {
    func didSelect(_ item: Item) -> AnyView
}

Where Item is just a struct which is identifiable, could be anything else (e.g id of some element like in a TableView in UIKit)

Next step is to adopt this protocol in Coordinator and simply pass the view you want to present:

extension Coordinator: ContentViewDelegate {
    func didSelect(_ item: Item) -> AnyView {
        AnyView(Text("Returned Destination1"))
    }
}

This has so far worked nicely in my apps. I hope it helps.

Solution 3:[3]

I will try to answer your points one by one. I will follow a little example where our View that should be reusable is a simple View that shows a Text and a NavigationLink that will go to some Destination. I created a Gist: SwiftUI - Flexible Navigation with Coordinators if you want to have a look at my full example.

The design problem: NavigationLinks are hardcoded into the View.

In your example it is bound to the View but as other answers have already shown, you can inject the destination to your View type struct MyView<Destination: View>: View. You can use any Type conforming to View as your destination now.

But if the view containing this NavigationLink should be reusable I can not hardcode the destination. There has to be a mechanism which provides the destination.

With the change above, there are mechanisms to provide the type. One example is:

struct BoldTextView: View {
    var text: String

    var body: some View {
        Text(text)
            .bold()
    }
}
struct NotReusableTextView: View {
    var text: String

    var body: some View {
        VStack {
            Text(text)
            NavigationLink("Link", destination: BoldTextView(text: text))
        }
    }
}

will change to

struct ReusableNavigationLinkTextView<Destination: View>: View {
    var text: String
    var destination: () -> Destination

    var body: some View {
        VStack {
            Text(text)

            NavigationLink("Link", destination: self.destination())
        }
    }
}

and you can pass in your destination like this:

struct BoldNavigationLink: View {
    let text = "Text"
    var body: some View {
        ReusableNavigationLinkTextView(
            text: self.text,
            destination: { BoldTextView(text: self.text) }
        )
    }
}

As soon as I have multiple reusable screens I run into the logical problem that one reusable view (ViewA) needs a preconfigured view-destination (ViewB). But what if ViewB also needs a preconfigured view-destination ViewC? I would need to create ViewB already in such a way that ViewC is injected already in ViewB before I inject ViewB into ViewA. And so on....

Well, obviously you need some kind of logic that will determine your Destination. At some point you need to tell the view what view comes next. I guess what you're trying to avoid is this:

struct NestedMainView: View {
    @State var text: String

    var body: some View {
        ReusableNavigationLinkTextView(
            text: self.text,
            destination: {
                ReusableNavigationLinkTextView(
                    text: self.text,
                    destination: {
                        BoldTextView(text: self.text)
                    }
                )
            }
        )
    }
}

I put together a simple example that uses Coordinators to pass around dependencies and to create the views. There is a protocol for the Coordinator and you can implement specific use cases based on that.

protocol ReusableNavigationLinkTextViewCoordinator {
    associatedtype Destination: View
    var destination: () -> Destination { get }

    func createView() -> ReusableNavigationLinkTextView<Destination>
}

Now we can create a specific Coordinator that will show the BoldTextView when clicking on the NavigationLink.

struct ReusableNavigationLinkShowBoldViewCoordinator: ReusableNavigationLinkTextViewCoordinator {
    @Binding var text: String

    var destination: () -> BoldTextView {
        { return BoldTextView(text: self.text) }
    }

    func createView() -> ReusableNavigationLinkTextView<Destination> {
        return ReusableNavigationLinkTextView(text: self.text, destination: self.destination)
    }
}

If you want, you can also use the Coordinator to implement custom logic that determines the destination of your view. The following Coordinator shows the ItalicTextView after four clicks on the link.

struct ItalicTextView: View {
    var text: String

    var body: some View {
        Text(text)
            .italic()
    }
}
struct ShowNavigationLinkUntilNumberGreaterFourThenItalicViewCoordinator: ReusableNavigationLinkTextViewCoordinator {
    @Binding var text: String
    let number: Int
    private var isNumberGreaterThan4: Bool {
        return number > 4
    }

    var destination: () -> AnyView {
        {
            if self.isNumberGreaterThan4 {
                let coordinator = ItalicTextViewCoordinator(text: self.text)
                return AnyView(
                    coordinator.createView()
                )
            } else {
                let coordinator = ShowNavigationLinkUntilNumberGreaterFourThenItalicViewCoordinator(
                    text: self.$text,
                    number: self.number + 1
                )
                return AnyView(coordinator.createView())
            }
        }
    }

    func createView() -> ReusableNavigationLinkTextView<AnyView> {
        return ReusableNavigationLinkTextView(text: self.text, destination: self.destination)
    }
}

If you have data that needs to be passed around, create another Coordinator around the other coordinator to hold the value. In this example I have a TextField -> EmptyView -> Text where the value from the TextField should be passed to the Text. The EmptyView must not have this information.

struct TextFieldView<Destination: View>: View {
    @Binding var text: String
    var destination: () -> Destination

    var body: some View {
        VStack {
            TextField("Text", text: self.$text)

            NavigationLink("Next", destination: self.destination())
        }
    }
}

struct EmptyNavigationLinkView<Destination: View>: View {
    var destination: () -> Destination

    var body: some View {
        NavigationLink("Next", destination: self.destination())
    }
}

This is the coordinator that creates views by calling other coordinators (or creates the views itself). It passes the value from TextField to Text and the EmptyView doesn't know about this.

struct TextFieldEmptyReusableViewCoordinator {
    @Binding var text: String

    func createView() -> some View {
        let reusableViewBoldCoordinator = ReusableNavigationLinkShowBoldViewCoordinator(text: self.$text)
        let reusableView = reusableViewBoldCoordinator.createView()

        let emptyView = EmptyNavigationLinkView(destination: { reusableView })

        let textField = TextFieldView(text: self.$text, destination: { emptyView })

        return textField
    }
}

To wrap it all up, you can also create a MainView that has some logic that decides what View / Coordinator should be used.

struct MainView: View {
    @State var text = "Main"

    var body: some View {
        NavigationView {
            VStack(spacing: 32) {
                NavigationLink("Bold", destination: self.reuseThenBoldChild())
                NavigationLink("Reuse then Italic", destination: self.reuseThenItalicChild())
                NavigationLink("Greater Four", destination: self.numberGreaterFourChild())
                NavigationLink("Text Field", destination: self.textField())
            }
        }
    }

    func reuseThenBoldChild() -> some View {
        let coordinator = ReusableNavigationLinkShowBoldViewCoordinator(text: self.$text)
        return coordinator.createView()
    }

    func reuseThenItalicChild() -> some View {
        let coordinator = ReusableNavigationLinkShowItalicViewCoordinator(text: self.$text)
        return coordinator.createView()
    }

    func numberGreaterFourChild() -> some View {
        let coordinator = ShowNavigationLinkUntilNumberGreaterFourThenItalicViewCoordinator(text: self.$text, number: 1)
        return coordinator.createView()
    }

    func textField() -> some View {
        let coordinator = TextFieldEmptyReusableViewCoordinator(text: self.$text)
        return coordinator.createView()
    }
}

I know that I could also create a Coordinator protocol and some base methods, but I wanted to show a simple example on how to work with them.

By the way, this is very similar to the way that I used Coordinator in Swift UIKit apps.

If you have any questions, feedback or things to improve it, let me know.

Solution 4:[4]

Something that occurs to me is that when you say:

But what if ViewB also needs a preconfigured view-destination ViewC? I would need to create ViewB already in such a way that ViewC is injected already in ViewB before I inject ViewB into ViewA. And so on.... but as the data which at that time has to be passed is not available the whole construct fails.

it's not quite true. Rather than supplying views, you can design your re-usable components so that you supply closures which supply views on demand.

That way the closure which produces ViewB on demand can supply it with a closure which produces ViewC on demand, but the actual construction of the views can happen at a time when the contextual information that you need is available.

Solution 5:[5]

Here is a fun example of drilling down infinitely and changing your data for the next detail view programmatically

import SwiftUI

struct ContentView: View {
    @EnvironmentObject var navigationManager: NavigationManager

    var body: some View {
        NavigationView {
            DynamicView(viewModel: ViewModel(message: "Get Information", type: .information))
        }
    }
}

struct DynamicView: View {
    @EnvironmentObject var navigationManager: NavigationManager

    let viewModel: ViewModel

    var body: some View {
        VStack {
            if viewModel.type == .information {
                InformationView(viewModel: viewModel)
            }
            if viewModel.type == .person {
                PersonView(viewModel: viewModel)
            }
            if viewModel.type == .productDisplay {
                ProductView(viewModel: viewModel)
            }
            if viewModel.type == .chart {
                ChartView(viewModel: viewModel)
            }
            // If you want the DynamicView to be able to be other views, add to the type enum and then add a new if statement!
            // Your Dynamic view can become "any view" based on the viewModel
            // If you want to be able to navigate to a new chart UI component, make the chart view
        }
    }
}

struct InformationView: View {
    @EnvironmentObject var navigationManager: NavigationManager
    let viewModel: ViewModel

    // Customize your  view based on more properties you add to the viewModel
    var body: some View {
        VStack {
            VStack {
                Text(viewModel.message)
                .foregroundColor(.white)
            }
            .frame(width: 300, height: 300)
            .background(Color.blue)


            NavigationLink(destination: navigationManager.destination(forModel: viewModel)) {
                Text("Navigate")
            }
        }
    }
}

struct PersonView: View {
    @EnvironmentObject var navigationManager: NavigationManager
    let viewModel: ViewModel

    // Customize your  view based on more properties you add to the viewModel
    var body: some View {
        VStack {
            VStack {
                Text(viewModel.message)
                .foregroundColor(.white)
            }
            .frame(width: 300, height: 300)
            .background(Color.red)
            NavigationLink(destination: navigationManager.destination(forModel: viewModel)) {
                Text("Navigate")
            }
        }
    }
}

struct ProductView: View {
    @EnvironmentObject var navigationManager: NavigationManager
    let viewModel: ViewModel

    // Customize your  view based on more properties you add to the viewModel
    var body: some View {
        VStack {
            VStack {
                Text(viewModel.message)
                    .foregroundColor(.white)
            }
            .frame(width: 300, height: 300)
            .background(Color.green)
            NavigationLink(destination: navigationManager.destination(forModel: viewModel)) {
                Text("Navigate")
            }
        }
    }
}

struct ChartView: View {
    @EnvironmentObject var navigationManager: NavigationManager
    let viewModel: ViewModel

    var body: some View {
        VStack {
            VStack {
                Text(viewModel.message)
                    .foregroundColor(.white)
            }
            .frame(width: 300, height: 300)
            .background(Color.green)
            NavigationLink(destination: navigationManager.destination(forModel: viewModel)) {
                Text("Navigate")
            }
        }
    }
}

struct ViewModel {
    let message: String
    let type: DetailScreenType
}

enum DetailScreenType: String {
    case information
    case productDisplay
    case person
    case chart
}

class NavigationManager: ObservableObject {
    func destination(forModel viewModel: ViewModel) -> DynamicView {
        DynamicView(viewModel: generateViewModel(context: viewModel))
    }

    // This is where you generate your next viewModel dynamically.
    // replace the switch statement logic inside with whatever logic you need.
    // DYNAMICALLY MAKE THE VIEWMODEL AND YOU DYNAMICALLY MAKE THE VIEW
    // You could even lead to a view with no navigation link in it, so that would be a dead end, if you wanted it.
    // In my case my "context" is the previous viewMode, by you could make it something else.
    func generateViewModel(context: ViewModel) -> ViewModel {
        switch context.type {
        case .information:
            return ViewModel(message: "Serial Number 123", type: .productDisplay)
        case .productDisplay:
            return ViewModel(message: "Susan", type: .person)
        case .person:
            return ViewModel(message: "Get Information", type: .chart)
        case .chart:
            return ViewModel(message: "Chart goes here. If you don't want the navigation link on this page, you can remove it! Or do whatever you want! It's all dynamic. The point is, the DynamicView can be as dynamic as your model makes it.", type: .information)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
        .environmentObject(NavigationManager())
    }
}

Solution 6:[6]

This is a completely off-the-top-of-my-head answer, so will probably turn out to be nonsense, but I'd be tempted to use a hybrid approach.

Use the environment to pass through a single coordinator object - lets call it NavigationCoordinator.

Give your re-usable views some sort of identifier which is set dynamically. This identifier gives semantic information corresponding to the client application's actual use case and navigation hierarchy.

Have the re-usable views query the NavigationCoordinator for the destination view, passing their identifier and the identifier of the view type they are navigating to.

This leaves the NavigationCoordinator as a single injection point, and it's a non-view object which can be accessed outside the view hierarchy.

During setup you can register the right view classes for it to return, using some sort of matching with the identifiers it's passed at runtime. Something as simple as matching with the destination identifier might work in some cases. Or matching against a pair of host and destination identifiers.

In more complex cases you can write a custom controller which takes account of other app-specific information.

Since it is injected via the environment, any view can override the default NavigationCoordinator at any point and supply a different one to its subviews.

Solution 7:[7]

I've posted my solutions in an article - Routing in SwiftUI. Two solutions for routing in SwiftUI.

Here is an overview:

1. Router with trigger views. A router will return trigger subviews for all possible navigation routes to insert them into a presenting view. Such a subview code snippet will contain NavigationLink or .sheet modifier inside, as well as a destination view specified, and will use a state property, stored in the router, via binding. This way, the presenting view won’t depend on the navigation code and destination, only on a router protocol.

A presenting view example:

protocol PresentingRouterProtocol: NavigatingRouter {
    func presentDetails<TV: View>(text: String, triggerView: @escaping () -> TV) -> AnyView
}

struct PresentingView<R: PresentingRouterProtocol>: View {

    @StateObject private var router: R

    init(router: R) {
        _router = StateObject(wrappedValue: router)
    }

    var body: some View {
        NavigationView {
            router.presentDetails(text: "Details") {
                Text("Present Details")
                    .padding()
            }
        }
    }
}

A router example:

class PresentingRouter: PresentingRouterProtocol {

    struct NavigationState {
        var presentingDetails = false
    }

    @Published var navigationState = NavigationState()

    func presentDetails<TV: View>(text: String, triggerView: @escaping () -> TV) -> AnyView {
        let destinationView = PresentedView(text: text, router: BasePresentedRouter(isPresented: binding(keyPath: \.presentingDetails)))
        return AnyView(SheetButton(isPresenting: binding(keyPath: \.presentingDetails), contentView: triggerView, destinationView: destinationView))
    }
}

The SheetButton trigger view:

struct SheetButton<CV: View, DV: View>: View {

    @Binding var isPresenting: Bool

    var contentView: () -> CV
    var destinationView: DV

    var body: some View {
        Button(action: {
            self.isPresenting = true
        }) {
            contentView()
                .sheet(isPresented: $isPresenting) {
                    self.destinationView
                }
        }
    }
}

Source code: https://github.com/ihorvovk/Routing-in-SwiftUI-with-trigger-views

2. Router with type erased modifiers. A presenting view will be configured with general modifiers for presenting any other views: .navigation(router), .sheet(router). Being initialised with a router, those modifiers will track navigation state stored in the router via bindings and perform navigation when the router changes that state. The router also will have functions for all possible navigations. Those functions will change the state and trigger navigation as a result.

A presenting view example:

protocol PresentingRouterProtocol: Router {
    func presentDetails(text: String)
}

struct PresentingView<R: PresentingRouterProtocol>: View {

    @StateObject private var router: R

    init(router: R) {
        _router = StateObject(wrappedValue: router)
    }

    var body: some View {
        NavigationView {
            Button(action: {
                router.presentDetails(text: "Details")
            }) {
                Text("Present Details")
                    .padding()
            }.navigation(router)
        }.sheet(router)
    }
}

The custome .sheet modifier takes a router as a parameter:

struct SheetModifier: ViewModifier {

    @Binding var presentingView: AnyView?

    func body(content: Content) -> some View {
        content
            .sheet(isPresented: Binding(
                get: { self.presentingView != nil },
                set: { if !$0 {
                    self.presentingView = nil
                }})
            ) {
                self.presentingView
            }
    }
}

The base Router class:

class Router: ObservableObject {

    struct State {
        var navigating: AnyView? = nil
        var presentingSheet: AnyView? = nil
        var isPresented: Binding<Bool>
    }

    @Published private(set) var state: State

    init(isPresented: Binding<Bool>) {
        state = State(isPresented: isPresented)
    }
}

Subclasses only need to implement functions for available routes:

class PresentingRouter: Router, PresentingRouterProtocol {

    func presentDetails(text: String) {
        let router = Router(isPresented: isNavigating)
        navigateTo (
            PresentedView(text: text, router: router)
        )
    }
}

Source code: https://github.com/ihorvovk/Routing-in-SwiftUI-with-type-erased-modifiers

Both solutions separate navigation logic from the view layer. Both store navigation state in a router. It allows us to perform navigation and implement deep linking simply by changing routers’ state.

Solution 8:[8]

The problem is in static type checking, ie. to construct NavigationLink we need to provide some specific views for it. So if we need to break this dependencies we need type erasure, ie. AnyView

Here is a working demo of idea, based on Router/ViewModel concepts using type-erased views to avoid tight dependencies. Tested with Xcode 11.4 / iOS 13.4.

Let's start for the end of what we get and analyse it (in comments):

struct DemoContainerView: View {
    var router: Router       // some router
    var vm: [RouteModel]     // some view model having/being route model

    var body: some View {
        RouteContainer(router: router) {    // route container with UI layout
          List {
            ForEach(self.vm.indices, id: \.self) {
              Text("Label \($0)")
                .routing(with: self.vm[$0])    // modifier giving UI element
                                               // possibility to route somewhere
                                               // depending on model
            }
          }
        }
    }
}

struct TestRouter_Previews: PreviewProvider {
    static var previews: some View {
        DemoContainerView(router: SimpleRouter(), 
            vm: (1...10).map { SimpleViewModel(text: "Item \($0)") })
    }
}

Thus, we have pure UI w/o any navigation specifics and separated knowledge of where this UI can route to. And here is how it works:

demo

Building blocks:

// Base protocol for route model
protocol RouteModel {}  

// Base protocol for router
protocol Router {
    func destination(for model: RouteModel) -> AnyView
}

// Route container wrapping NavigationView and injecting router
// into view hierarchy
struct RouteContainer<Content: View>: View {
    let router: Router?

    private let content: () -> Content
    init(router: Router? = nil, @ViewBuilder _ content: @escaping () -> Content) {
        self.content = content
        self.router = router
    }

    var body: some View {
        NavigationView {
            content()
        }.environment(\.router, router)
    }
}

// Modifier making some view as routing element by injecting
// NavigationLink with destination received from router based
// on some model
struct RouteModifier: ViewModifier {
    @Environment(\.router) var router
    var rm: RouteModel

    func body(content: Content) -> some View {
        Group {
            if router == nil {
                content
            } else {
                NavigationLink(destination: router!.destination(for: rm)) { content }
            }
        }
    }
}

// standard view extension to use RouteModifier
extension View {
    func routing(with model: RouteModel) -> some View {
        self.modifier(RouteModifier(rm: model))
    }
}

// Helper environment key to inject Router into view hierarchy
struct RouterKey: EnvironmentKey {
    static let defaultValue: Router? = nil
}

extension EnvironmentValues {
    var router: Router? {
        get { self[RouterKey.self] }
        set { self[RouterKey.self] = newValue }
    }
}

Testing code shown in demo:

protocol SimpleRouteModel: RouteModel {
    var next: AnyView { get }
}

class SimpleViewModel: ObservableObject {
    @Published var text: String
    init(text: String) {
        self.text = text
    }
}

extension SimpleViewModel: SimpleRouteModel {
    var next: AnyView {
        AnyView(DemoLevel1(rm: self))
    }
}

class SimpleEditModel: ObservableObject {
    @Published var vm: SimpleViewModel
    init(vm: SimpleViewModel) {
        self.vm = vm
    }
}

extension SimpleEditModel: SimpleRouteModel {
    var next: AnyView {
        AnyView(DemoLevel2(em: self))
    }
}

class SimpleRouter: Router {
    func destination(for model: RouteModel) -> AnyView {
        guard let simpleModel = model as? SimpleRouteModel else {
            return AnyView(EmptyView())
        }
        return simpleModel.next
    }
}

struct DemoLevel1: View {
    @ObservedObject var rm: SimpleViewModel

    var body: some View {
        VStack {
            Text("Details: \(rm.text)")
            Text("Edit")
                .routing(with: SimpleEditModel(vm: rm))
        }
    }
}

struct DemoLevel2: View {
    @ObservedObject var em: SimpleEditModel

    var body: some View {
        HStack {
            Text("Edit:")
            TextField("New value", text: $em.vm.text)
        }
    }
}

struct DemoContainerView: View {
    var router: Router
    var vm: [RouteModel]

    var body: some View {
        RouteContainer(router: router) {
            List {
                ForEach(self.vm.indices, id: \.self) {
                    Text("Label \($0)")
                        .routing(with: self.vm[$0])
                }
            }
        }
    }
}

// MARK: - Preview
struct TestRouter_Previews: PreviewProvider {
    static var previews: some View {
        DemoContainerView(router: SimpleRouter(), vm: (1...10).map { SimpleViewModel(text: "Item \($0)") })
    }
}

Solution 9:[9]

Really interesting topic the one you're discussing here guys. To put my penny here, I will share my thoughts. I did try to mainly focus on the problem without opinionating it too much.

Let's say you're building a UI Components framework that you would need to ship within your company worldwide. Then the requirement you have it's building "dummy" components that will now how to present themselves and some extra minimal knowledge, like if they likely will have navigation or not.

Assumptions:

  • ViewA component will live in a UI isolated Framework.
  • ViewA component will likely know that somehow from there it would be able to navigate. But ViewA doesn't care to much about the type of what's living within it. It will just provide it's own "potentially" navigatable view and that's it. Therefore the "contract" that will be establish is. A higher-order-component erased typed builder (inspired by React, who would tell me after many years in iOS :D) that will receive a view from the component. And this builder would provide a View. That's it. ViewA does not need to know anything else.

ViewA

/// UI Library Components framework.

struct ViewAPresentable: Identifiable {
    let id = UUID()
    let text1: String
    let text2: String
    let productLinkTitle: String
}

struct ViewA: View {
    let presentable: ViewAPresentable
    let withNavigationBuilder: (_ innerView: AnyView) -> AnyView

    var body: some View {
        VStack(alignment: .leading,
               spacing: 10) {
            HStack(alignment: .firstTextBaseline,
                   spacing: 8) {
                    Text(presentable.text1)
                    Text(presentable.text2)
                }

                withNavigationBuilder(AnyView(Text(presentable.productLinkTitle)))
        }
    }
}

Then;

  • We have a HostA, which will consume that component, and actually wants to provide a navigatable link on that HOC.
/// HOST A: Consumer of that component.

struct ConsumerView: View {
    let presentables: [ViewAPresentable] = (0...10).map {
        ViewAPresentable(text1: "Hello",
                         text2: "I'm \($0)",
            productLinkTitle: "Go to product")
    }

    var body: some View {
        NavigationView {
            List(presentables) {
                ViewA(presentable: $0) { innerView in
                    AnyView(NavigationLink(destination: ConsumerView()) {
                        innerView
                    })
                }
            }
        }
    }
}

But actually another consumer B. Doesn't want to provide a navigatable link, it will provide just the inner component as it is given the requirement in Consumer B is to not be navigable.

/// HOST B: Consumer of that component. (But here it's not navigatable)

struct ConsumerBView: View {
    let presentables: [ViewAPresentable] = (0...10).map {
        ViewAPresentable(text1: "Hello",
                         text2: "I'm \($0)",
            productLinkTitle: "Product description not available")
    }

    var body: some View {
        NavigationView {
            List(presentables) {
                ViewA(presentable: $0) { innerView in
                    AnyView(innerView)
                }
            }
        }
    }
}

By checking the code above, we can have isolated components with the bare minimum contract established. I went to type erasure because actually here, the type erasure is implicitly required by the context. ViewA actually doesn't care about what to be placed within there. Will be responsibility of the consumer.

Then based on this, you can abstract further your solution with FactoryBuilders, Coordinators and so on. But actually the root of the problem it's solved.

Solution 10:[10]

I decided to have a go on the problem as well.

One could easily argue that dependency injection via environment would be a cleaner approach, and indeed in many ways it can be, but I have decided against it as it does not allow using generic data type as context information at the site of destination determination. In other words, you cannot inject generics into environment without specializing them beforehand.

Here’s the pattern I’ve decided to use instead…

On the framework side

Protocol for Segue Coordination

At the core of the solution is one protocol Segueing.

protocol Segueing {
    associatedtype Destination: View
    associatedtype Segue
    
    func destination(for segue: Segue) -> Destination
}

What it does is define a contract that any segue coordinator attached to a view must be able to provide another view, as a destination, in response to a concrete segue.

Note that segue does not need to be an enumeration, but it is practical to use a finite enumeration augmented by associated types to carry necessary context for the purpose.

Segue Enumeration

enum Destinations<Value> {
    case details(_ context: Value)
}

Here’s an example that defines a single segue “details” and takes an arbitrary type Value to carry context of the user choice, and in a type safe manner. It’s a design choice whether to use a single segue enumeration for a group of views tightly working together or have each view define its own. The latter being a more preferable option if each view brings along its own generic types.

View

struct ListView<N: Segueing, Value>: View where N.Segue == Destinations<Value>, Value: CustomStringConvertible & Hashable {
    var segues: N
    var items: [Value]
    
    var body: some View {
        NavigationView {
            List(items, id: \.self) { item in
                NavigationLink(destination: self.segues.destination(for: .details(item))) {
                    Text("\(item.description)")
                }
            }
        }
    }
}

Here’s an example of a list view for generic Value type. We also establish a relationship between segue coordinator N: Segueing and segue enumeration Destinations. So this view accepts a segue coordinator that responds to destination queries based on available segues in Destinations and passes on the user selected value to the coordinator for decision making.

It is possible to define a default segue coordinator by conditionally extending the view and introducing a new convenience initializer as below.

extension ListView where N == ListViewSegues<Value> {
    init(items: [Value]) {
        self = ListView(segues: ListViewSegues(), items: items)
    }
}

This is all defined inside the framework or a swift package.

On the client side

Segue Coordinators

struct ListViewSegues<Value>: Segueing where Value: CustomStringConvertible {
    func destination(for segue: Destinations<Value>) -> some View {
        switch segue {
            case .details(let value):
            return DetailView(segues: DetailViewSegues(), value: value)
        }
    }
}

struct DetailViewSegues<Value>: Segueing where Value: CustomStringConvertible {
    func destination(for segue: Destinations<Value>) -> some View {
        guard case let .details(value) = segue else { return AnyView(EmptyView()) }
        return AnyView(Text("Final destination: \(value.description)")
                .foregroundColor(.white)
                .padding()
                .background(Capsule()
                .foregroundColor(.gray))
        )
    }
}

On the client side we need to create a segue coordinator. Above we can see an example of responding to a single segue choice by instantiating another view from the framework DetailView. We provide another segue coordinator and pass on the value (of user choosing) to the detail view.

At call site

var v1 = ListView(segues: ListViewSegues(), items: [7, 5, 12])
var v2 = ListView(segues: ListViewSegues(), items: ["New York", "Tokyo", "Paris"])
var v3 = ListView(items: ["New York", "Tokyo", "Paris"])

Benefits

  1. Views can be made reusable and factored out into a separate module such as framework or swift package.
  2. Navigation destinations can be customized on client side and do not need to be pre-configured.
  3. Strong (context) type information is available at view construction site.
  4. Deep view hierarchies do not result in nested closures.

Solution 11:[11]

Here's another suggested solution decoupling Views and destination Views using Routers. As you can see the presented View type and presentation styles are abstracted away from the presenting View.

If you think the solution or sample code attached below has any architectural drawbacks please let me know.

Router:

import SwiftUI

protocol DetailsFeatureRouting {
    func makePushDetailsView<Label: View>(viewModel: GrapeViewModel, @ViewBuilder label: () -> Label) -> AnyView
    func makeModalDetailsView<Label: View>(viewModel: GrapeViewModel, @ViewBuilder label: () -> Label) -> AnyView
}

extension DetailsFeatureRouting {
    func makePushDetailsView<Label: View>(viewModel: GrapeViewModel, @ViewBuilder label: () -> Label) -> AnyView {
        label()
            .makeNavigation {
                DetailsView.make(viewModel: viewModel)
            }
            .anyView
    }

    func makeModalDetailsView<Label: View>(viewModel: GrapeViewModel, @ViewBuilder label: () -> Label) -> AnyView {
        label()
            .makeSheet {
                NavigationView {
                    DetailsView.make(viewModel: viewModel)
                }
            }
            .anyView
    }
}

RootView

struct RootView: View {
    @StateObject var presenter: RootPresenter

    var body: some View {
        NavigationView {
            List {
                ForEach(presenter.viewModels) { viewModel in
                    presenter.makeDestinationView(viewModel: viewModel) {
                        VStack(alignment: .leading) {
                            Text(viewModel.title)
                                .font(.system(size: 20))
                                .foregroundColor(.primary)
                                .lineLimit(3)
                            Text(viewModel.subtitle)
                                .font(.caption)
                                .foregroundColor(.secondary)
                        }
                    }
                }
            }
            .navigationTitle("Grapes")
        }
    }
}

The entire project is here https://github.com/nikolsky2/FeatureRoutingSwiftUI

Solution 12:[12]

Despite its a year ago, this is an interesting and still actual question. IMHO, we still need to discover good solutions and best practices for common problems.

I don't think though, the Coordinator pattern in UIKIt is a good solution for the problem it strives to solve and a correct application rises a lot of headaches and left many questions unanswered how to integrate it with the rest of the architecture.

In SwiftUI everything seems that static and "predefined" that we struggle so find a way to get some dynamism into it. So, the same problem still exists, too in SwiftUI.

The following approach decouples two of the three aspects for Navigation (creation, transition and configuration), and leaves the transition aspect where it should stay (IMHO): in the source view.

The two other aspects creation (of the destination view and configuration) is performed in a dedicated "Coordinator" View which is a parent view of the source view in the view hierarchy.

Note: a SwiftUI view is not a View as it was in UIKit. It is merely a means to create and modify a "View" which lives behind the scenes and will be managed by SwiftUI. So, using a view which performs solely setup and configuration is IMHO a total valid and useful approach anyway. A proper naming and a convention will help to identify these views.

The solution is pretty light weight. If there is the need to further decouple certain aspects - like making the kind of destination view not only dependent on the element but also from some property in some environment, I would not resort to anything like the Coordinator pattern like it was invented for UIKit. In SwiftUI we have better alternatives. I would use common techniques like the "Reader Monad" which decomposes application and configuration and makes it possible to have two "far away" locations where you implement the one and the other aspect - which is basically a form of Dependency Injection.

So, given this scenario:

  • we have a list view which shows elements
  • each element can be displayed in a detail view via a Navigation Link.
  • the kind of the detail view depends on certain properties of the element
import SwiftUI
import Combine

struct MasterView: View {

    struct Selection: Identifiable {
        let id: MasterViewModel.Item.ID
        let view: () -> DetailCoordinatorView  // AnyView, if you 
                                               // need strong decoupling
    }

    let items: [MasterViewModel.Item]
    let selection: Selection?
    let selectDetail: (_ id: MasterViewModel.Item.ID) -> Void
    let unselectDetail: () -> Void

    func link() -> Binding<MasterViewModel.Item.ID?> {
        Binding {
            self.selection?.id
        } set: { id in
            print("link: \(String(describing: id))")
            if let id = id {
                selectDetail(id)
            } else {
                unselectDetail()
            }
        }
    }

    var body: some View {
        List {
            ForEach(items, id: \.id) { element in
                NavigationLink(
                    tag: element.id,
                    selection: link()) {
                        if let selection = self.selection {
                            selection.view()
                        }
                    } label: {
                        Text("\(element.name)")
                    }
            }
        }
    }
}

The Master View has no knowledge of the Detail View. It uses only one Navigation Link to effectively show different kind of detail views. It also does not know the mechanics which determines the kind of detail view. However it knows and determines the kind of transition.

struct DetailView: View {
    let item: DetailViewModel.Item

    var body: some View {
        HStack {
            Text("\(item.id)")
            Text("\(item.name)")
            Text("\(item.description)")
        }
    }
}

Just a detail view for demonstration.

struct MasterCoordinatorView: View {
    @ObservedObject private(set) var viewModel: MasterViewModel

    var body: some View {
        MasterView(
            items: viewModel.viewState.items,
            selection: detailSelection(),
            selectDetail: viewModel.selectDetail(id:),
            unselectDetail: viewModel.unselectDetail)
    }

    func detailSelection() -> MasterView.Selection? {
        let detailSelection: MasterView.Selection?
        if let selection = viewModel.viewState.selection {
            detailSelection = MasterView.Selection(
                id: selection.id,
                view: {
                    // 1. Decision point where one can create 
                    //    different kind of views depending on 
                    //    the given element.
                    DetailCoordinatorView(viewModel: selection.viewModel)
                        //.eraseToAnyView()  // if you need 
                                             // more decoupling
                }
            )
        } else {
            detailSelection = nil
        }
        return detailSelection
    }
}

The MasterCoordinatorView is responsible to setup the mechanics for the Navigation and also decouples the ViewModel from the View.

struct DetailCoordinatorView: View {
    @ObservedObject private(set) var viewModel: DetailViewModel

    var body: some View {
        // 2. Decision point where one can create different kind
        // of views depending on the given element, using a switch
        // statement for example.
        switch viewModel.viewState.item.id {
        case 1:
            DetailView(item: viewModel.viewState.item)
                .background(.yellow)
        case 2:
            DetailView(item: viewModel.viewState.item)
                .background(.blue)
        case 3:
            DetailView(item: viewModel.viewState.item)
                .background(.green)
        default:
            DetailView(item: viewModel.viewState.item)
                .background(.red)
        }
    }
}

Here, the DetailCoordinatorView is responsible to the select the detail view.

Finally, the View Models:

final class MasterViewModel: ObservableObject {

    struct ViewState {
        var items: [Item] = []
        var selection: Selection? = nil
    }

    struct Item: Identifiable {
        var id: Int
        var name: String
    }

    struct Selection: Identifiable {
        var id: Item.ID
        var viewModel: DetailViewModel
    }

    @Published private(set) var viewState: ViewState

    init(items: [Item]) {
        self.viewState = .init(items: items, selection: nil)
    }

    func selectDetail(id: Item.ID) {
        guard let item = viewState.items.first(where: { id == $0.id } ) else {
            return
        }
        let detailViewModel = DetailViewModel(
            item: .init(id: item.id,
                        name: item.name,
                        description: "description of \(item.name)",
                        image: URL(string: "a")!)
        )
        self.viewState.selection = Selection(
            id: item.id,
            viewModel: detailViewModel)
    }

    func unselectDetail() {
        self.viewState.selection = nil
    }
}

final class DetailViewModel: ObservableObject {

    struct Item: Identifiable, Equatable {
        var id: Int
        var name: String
        var description: String
        var image: URL
    }

    struct ViewState {
        var item: Item
    }

    @Published private(set) var viewState: ViewState


    init(item: Item) {
        self.viewState = .init(item: item)
    }

}

For playgrounds:

struct ContentView: View {
    @StateObject var viewModel = MasterViewModel(items: [
        .init(id: 1, name: "John"),
        .init(id: 2, name: "Bob"),
        .init(id: 3, name: "Mary"),
    ])

    var body: some View {
        NavigationView {
            MasterCoordinatorView(viewModel: viewModel)
        }
        .navigationViewStyle(.stack)
    }
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(ContentView())


extension View {
    func eraseToAnyView() -> AnyView {
        AnyView(self)
    }
}

Solution 13:[13]

I am the author of Navigator a library that decouples the View from the NavigationLink. All navigation destinations can be called at runtime. No hardcoded static destination views

It basically creates a delegation object navigator lifted out of the View that can call all basic navigation operations

  • navigate(to:) pushes a View onto the NavigationView
  • pop pops the current View off of the NavigationView
  • popToRoot() pops all views off the NavigationView to reveal the root View

Uses the underlying SwiftUI navigation paradigm (NavigationLink), no custom Navigation or wrapper views

It also keeps keeps track of the navigation stack and allows for custom navigation logic. Here is a snippet

struct DetailScreen: ScreenView {
    @EnvironmentObject var navigator: Navigator<ScreenID, MyViewFactory>    
    @State var showNextScreen: Bool = false
    var screenId: ScreenID
    
    var body: some View {
        VStack(spacing: 32) {
            Button("Next") {
                navigator.navigate(to: calculateNextScreen())
            }
            .tint(.blue)


            Button("Dismiss") {
                navigator.pop()
            }
            .tint(.red)

        }
        .navigationTitle("Detail Screen")
        .bindNavigation(self, binding: $showNextScreen)
    }
}