Navigation and Deep-Links in SwiftUI

Two people standing in front of a maze, feeling lost

Large, complex apps rely on a robust, yet flexible app architecture. In this article, we’ll show you how to accomplish this goal and how to handle navigation and deep-linking with SwiftUI.

With more and more people building production apps in SwiftUI, we recently started a pursuit to try out, discuss and compare different app architectures. In a previous blog article, we compared three widely different app architecture approaches:

  • Model-View
  • Redux
  • and Model-View-ViewModel (MVVM)

In another blog article, we showed how the Coordinator Pattern can be applied in SwiftUI, a pattern that’s very popular among developers with UIKit.

In this article, let’s have a look at what we gain from the Coordinator Pattern in terms of navigation and deep linking. We’ll further improve our implementation to work with larger apps and finally discuss our approach.

Alongside this article, we open-source the XUI library which allows you to build an MVVM-C app architecture in SwiftUI. It provides access to all code components we’ll introduce in this article and plenty of useful Combine and SwiftUI extensions.



Benefits of the Coordinator Pattern

The Coordinator Pattern extracts transition logic as well as the creation and organization of view models from existing views and view models. It makes our view models less coupled. As a result, views can be reused in different view contexts, e.g. we can easily move them from a TabView to a NavigationView without changing the internal code of that view. This drastically increases the flexibility and maintainability of our views and view models.

Improvements

With our approach for implementing the Coordinator Pattern we introduced before, we already achieved most of these benefits. But the decoupling isn’t complete yet. We now want to make this concept even more powerful by allowing view models to be abstracted by protocols. We’ll allow direct access to different components of our coordinator hierarchy for deep links and provide solutions to common issues when dealing with multiple ObservableObject instances.


Protocol Abstraction

In our projects, we would like to decouple the business logic from our views. That’s why we use view models. However, a view (MyView) always references a view model (MyViewModel), so if we want to use the same view in different contexts, we would have to duplicate the view in order to change the view model type (MyViewModelMyOtherViewModel).

So there is still a tight coupling between a given view and a specific view model that we need to get rid of. Normally, we would simply write a protocol for this in order to mask the view model’s concrete type. Unfortunately, the following code does not compile:

protocol SomeViewModel: ObservableObject {
    // ...
}

struct SomeView: View {
    @ObservedObject var viewModel: SomeViewModel
}

There are two issues with using protocols as view models in SwiftUI:

  1. ObservableObject has an associated type requirement ObjectWillChangePublisher, as we can see in its declaration:
protocol ObservableObject: AnyObject {
    associatedtype ObjectWillChangePublisher: Publisher = ObservableObjectPublisher 
        where Self.ObjectWillChangePublisher.Failure == Never

    var objectWillChange: Self.ObjectWillChangePublisher { get } 
}

As SomeViewModel conforms to ObservableObject, it inherits the associated type requirement. Therefore, it can only be used as a generic constraint, but not as a concrete type.

  1. ObservedObject (the property wrapper used for ObservableObject properties) has a generic constraint with a requirement that forces us to specify a concrete class or struct and doesn’t allow protocols in general. This is simply a language restriction.

Okay, so what can we do about that?

Option 1: Generic Constraint

The first idea that comes to mind is generics:

struct SomeView<ViewModel: SomeViewModel>: View {
    @ObservedObject var viewModel: ViewModel
}

Well, it’s not that easy. This will still force us to know the concrete type of SomeViewModel when creating SomeView. In our coordinator approach, however, the view only knows the protocol a view model conforms to, but not the concrete type.

We could extend our view model protocol by a method that creates our view, but that would not really be practical to write for each and every view.

extension SomeViewModel {

    var someView: some View {
        SomeView(viewModel: self)
    }

}

In this extension, the compiler knows the concrete type of the view model, and therefore, we can create our view just as normal.

Option 2: The Store Property Wrapper

Instead of using the ObservedObject property wrapper, we can create our own property wrapper. In our new XUI library, we have defined the Store property wrapper that can be used similarly to ObservedObject:

protocol SomeViewModel: AnyObservableObject {
    // ...
}

struct SomeView {
    @Store var viewModel: SomeViewModel
}

Looks just like what we wanted, doesn’t it?

As you might have noticed, our view model protocol no longer requires conformance to ObservableObject, but to AnyObservableObject instead.

protocol AnyObservableObject: AnyObject {
    var objectWillChange: ObservableObjectPublisher { get }
}

AnyObservableObject is a protocol we created that comes without an associated type requirement. Instead, it requires a property with the same name and the concrete type that ObservableObject classes usually have. → Issue 1 solved! ✅

Now, the only issue remaining is that we have to specify a concrete type for properties wrapped in ObservedObject. For this purpose, let’s take another look at our Store property wrapper:

It allows us to use protocols as generic types (i.e. you can create a Store<SomeViewModel>) and instead of forcing conformance to AnyObservableObject, it internally makes the assumption that it is only used with values conforming to AnyObservableObject. For all SomeViewModel implementations, this is already the case. Thus, we have found a much more flexible and easy-to-use solution!

We don’t want to dive deeper into the implementation details of the Store property wrapper as part of this article, but if you’re interested, please go ahead and have a look at our XUI library.

ℹ️ Note: We also cannot make AnyObservableObject conform to Identifiable since protocols cannot conform to protocols, which is why we recreated the different view modifiers to be used, for example

.sheet(model: $coordinator.detailViewModel) { ... }

instead of

.sheet(item: $coordinator.detailViewModel) { ... }

Deep Links

Sometimes, we would like to open our app in a certain state, for example, when

  • the user taps a notification
  • the app is opened via a URL
  • the user wants to use Handoff to switch between different devices for the same app.

In all of these scenarios, we would like to perform multiple transitions right at startup in order to navigate to a certain UI state for displaying the requested information.

Oftentimes, deep links are implemented using complicated, hard-to-trace chains of commands throughout the entire application. New developers on your team (or even you yourself a couple of months later) will have a hard time understanding when a specific method in this chain is performed and whether it’s actually needed or not.

But normally, the user can reach the target view of any deep-link by going through the app manually step-by-step, so why don’t we just simulate these steps by triggering the same actions on our coordinator objects and view models directly?

We have identified two approaches we would like to introduce to you. Let’s assume the following: We have an app for recipes (the one we introduced in our previous article) that displays two tabs with lists of recipes. One tab only shows the vegetarian recipes while the other tab only shows the non-vegetarian ones. For each recipe, you can open preparation instructions in a detail view, and from there, you can display a modal sheet with the recipe’s ratings. We now want this app to support opening the ratings for a specific recipe by URL.

Approach 1

In this example, we assume that we have already figured out which recipe the app should display the ratings for. (The logic for mapping the deep-link URL to the specific recipe depends on the URL scheme you use and is not relevant for this article.)

class DefaultHomeCoordinator: HomeCoordinator, ObservableObject {

    // ...

    func openRatings(for recipe: Recipe) {
        tab = recipe.isVegetarian ? .veggie : .meat
        let recipeListCoordinator: RecipeListCoordinator = recipe.isVegetarian ? veggieCoordinator : meatCoordinator
        recipeListCoordinator.open(recipe)
        let recipeViewModel: RecipeViewModel? = recipeListCoordinator.detailViewModel
        recipeViewModel?.openRatings()
    }

}

Great, we have found a solution! But not so fast, because we have also created a few restrictions about the overall structure of the application:

In some cases, we might only know which actions we want to trigger, but not what each coordinator object or view model does internally. A coordinator might have many different child view models or coordinator objects depending on which model object is to be shown to the user. It would be a lot simpler if we could just search for any object that conforms to a certain type and/or that fulfills a certain requirement.

Approach 2

In order to be able to search for objects in the coordinator/view model hierarchy, we will need to make that hierarchy visible to the developer. We do that by creating a simple protocol DeepLinkable.

protocol DeepLinkable {
    var children: [DeepLinkable] { get }
}

With this protocol, we can now easily see that our app’s structure of is quite similar to a tree hierarchy with coordinator objects acting as branches, child coordinators as even smaller branches, and view models as their leaves. Each coordinator can now implement the children property to provide access to its children. (Hint: When using protocol abstractions, you can write an extension on the protocol. The default implementation simply returns no children.)

For our recipes app, the hierarchy looks like this:

Tree of coordinators and view models

We have now implemented a breadth-first-search for a specific object in that hierarchy to be easily extracted. Breadth-first-search looks for coordinators layer by layer in the tree. The numbers associated with each view model or coordinator object corresponds to the order in which each coordinator object or view model is traversed.

Let me show how we can use that search in action by translating the above code example so that it uses the firstReceiver method defined on DeepLinkable.

class DefaultHomeCoordinator: HomeCoordinator, ObservableObject {

  // ...

  func openRatings(for recipe: Recipe) {
      tab = recipe.isVegetarian ? .veggie : .meat
      let recipeListCoordinator = firstReceiver(as: RecipeListCoordinator.self, where: { 
          $0.filter(recipe) 
      })
      recipeListCoordinator.open(recipe)
      let recipeViewModel =  firstReceiver(as: RecipeViewModel.self, where: { 
          $0.recipe.id == recipe.id 
      })
      recipeViewModel.openRatings()
  }

}

As you can see, we no longer need to know about the entire structure of the application, but can rather make a shortcut of simply knowing the type of a required object.

⚠️ Warning: Deep-links still make assumptions about parts of the structure of your application, possibly breaking once you introduce changes later on during development or maintenance. In these cases, you will either need to adapt the deep-link or use approach 1 instead.

Which approach is best?

As you can see, there are different approaches for implementing deep-links with coordinators in SwiftUI. Using the coordinator interfaces to get individual child coordinators and view models, we can create safe deep links. However, we will need to adapt these deep links, whenever a property is renamed or the structure is even minimally manipulated.

By understanding the coordinator hierarchy as a graph, we can make our deep links become more flexible at the cost of safety. Automated tests allow for maximal flexibility while ensuring that deep-links are still working properly.


Conclusion

In this article, we have seen that coordinators bring plenty of benefits to our apps and how we can use protocol abstraction and implement deep-linking to make our coordinators even more powerful. We are providing many of the components (and more!) in our new open-source framework XUI. XUI makes handling MVVM and MVVM-C architectures simpler and provides some useful extensions to SwiftUI and Combine.

Of course, the Coordinator Pattern, just as in UIKit, comes with downsides. Coordinators create additional objects, which means creating more code and thereby complexity. In comparison to even more complex and abstract architectures, like VIPER, MVVM-C can easily be mixed and matched with MVVM or other patterns. You should decide on a case-by-case basis when to go for the full abstraction with coordinators and separate view models and when it’s possible to share a view model across different views. This is a lot more flexible than it often is in UIKit.

In the end, the architecture should be decided according to the concrete application at hand. We propose the usage of our Coordinator approach in larger apps, where reuse and testability can easily increase maintainability. For smaller apps with little functionality or just a handful of screens, coordinators would only consist of a few lines of code and you would not really benefit from the abstraction.

🐣

Get notified when our next article is born!

(no spam, just one app-development-related article per month)