Jump 🐒 SwiftUI Coordinator with Router

Felipe Fernandes
6 min readFeb 27, 2023

A simple way to coordinate flows with SwiftUI

Jump UITests

Currently is still hard to do routing with pure SwiftUI without wrap views inside UIHostingController, moreover, it’s even harder to understand the flow of the screens once the routing system is created. SwiftUI has a navigation system that relies on modifiers and struct like NavigationStack. Yet whenever we go for the native solutions we find ourselves in complicated resolutions and unclear navigation flow.

Here are some known problems I encounter while doing navigation with SwiftUI:

  • The modifiers responsible for the presentation have to be defined inside the view
  • Many times we need to pass data from one ViewModel to another ViewModel, despite swiftUI having @EnvironmentObject that makes things easier for such requirements but it’s not obvious.
  • The view shouldn’t be aware of where it will go but only dispatch actions. ( This one is my POV )
  • It becomes problematic when we need to do traversal navigation
  • We have to rely a lot on @EnvironmentObject objects
  • There isn’t a centralized place that makes us understand how the navigation in that flow works
  • A view can be pushed in one situation or presented in another situation, this logic becomes cumbersome inside the view itself. Moreover, it couples the view to its expected presentation
  • Some modifiers don’t work as expected, such as onAppear or onDisappear ( sometimes they are called before the view totally appears or disappears) and some can be called twice.

“Success is not final; failure is not fatal: It is the courage to continue that counts.” -Winston S. Churchill

What is Jump?

Jump is a framework that covers a common problem in iOS development (Routing Views with SwiftUI). SwiftUI is a declarative UI framework released by apple to facilitate UI composition. Currently, every view that has to be presented with swiftUI is totally coupled with its previous view. And many times we find ourselves creating workarounds to decouple it.

Jump adds a coordinator that helps to organize the routing system of a flow, and also becomes a reference point to understand what is happening in that flow without having to browse many files of views and read a lot of lines of code.

Here are the main features of Jump:

Let’s get started

Jump is a swift package and to install it you have to follow these steps:

In Xcode, add the dependency to your project via File > Add Packages > Search or Enter Package URL and use the following URL:

https://github.com/nandzz/Jump-SwiftUI-Coordinator

Once added, import the package in your code:

import SwiftUIRouter

Define Paths 🛣

Every flow has its given paths ( the view’s routing name ), so a path corresponds to a View. But what are these paths? If inside an App we have a section called Profile, this section can contain many paths:

Profile {
root
changePicture
settings
editBio
badges
}

Here is how you create these paths for Jump by using an enum. The enum has to conform to ContextPath and can be called with the name of your choice. The flow’s paths can be declared in a file inside a folder called Coordinator together with the Coordinator file

enum ProfilePaths: ContextPath {
case root
case changePicture
case settings
case editBio
case badges
}

Create the Coordinator 🤟

The coordinator has to conform to Coordinator Type and its associated paths ( the type we just have created in the section above ) Provide a file inside a coordinator folder for this declaration. Name your file with the name of the Coordinator

class ProfileCoordinator: Coordinator<ProfilePaths> {}

Create Views 📺

Each view using jump has to conform to ContextView Here is how to declare the View:

You can avoid to write the typealias by declaring directly the presenter as below

struct ContentView: ContextView {

var presenter: ContextPresenter<ProfilePaths>

var body: some View {
ContextContent<ProfilePaths>(presenter) { dispatch in
// Use ContextContent to wrap your view passing the paths you created
// to its generic type and initialising it with the presenter injected.
}
}
}

Create Actions 👨‍💻

Every view has its actions. Taking the example of the Profile section we can have the following actions for the root view

  • didTapOnChangePicture
  • didTapOnSettings
  • didTapOnEditBio
  • didTapOnBadges
  • didTapOnDismiss
  • dismissAfterError
  • viewModelDidProduceWarning
  • idle

Actions can also be consequences of state changes in the viewModel (ex. Network Error, API Call succeeded)

⚠️ You always need to have an idle action inside the enum

Here is an example of how to define the profile actions:

public enum ProfileRootActions: ContextAction {
case idle
// Actions from interaction
case didTapOnChangePicture
case didTapOnSettings
case didTapOnEditBio
case didTapOnBadges

// Actions from state change
case dismissAfterError(error:)
case viewModelDidProduceWarning
}

Piece of Adivise: declate these actions over the declarion of the view, so they can be easily found. The actions has to conform to Equatable and Hashable as well as any associated information that will be sent to the coordinator using associated types. You can conform the actions directly to ContextAction that implements Equatable and Hashable protocols.

Take decisions 🚦

Give Actions to the Paths

Previously we have declared the paths for the Profile with an enum. Now we gonna give actions to each one of these paths.

Remember: Each View has its path and action defined. You have to associate the actions with the path:

enum ProfilePaths: ContextPath {
case root(ProfileRootActions)
case changePicture(ProfileChangePictureActions = .idle)
case settings(ProfileSettingsActions = .idle)
case editBio(ProfileEditBioActions = .idle)
case badges(ProfileBadgesActions = .idle)
}

By Having an idle action inside the enum of actions we are able to init the path without a given action.

Dispatch the actions

Now from this view, we can dispatch actions to the coordinator

class ProfileRootViewModel: ObservableObject {

enum Status {
case error
case warning
case ready
}

var status: Status = .ready
}

struct ProfileRootView: ContextView {

var presenter: ContextPresenter<ProfilePaths>
@ObservedObject var viewModel: ProfileViewModel

var body: some View {
ContextContent<ProfilePaths>(presenter) { dispatch in
VStack {
Button("Edit Bio") {
dispatch(.root(.didTapOnEditBio))
}

Button("Settings") {
dispatch(.root(.didTapOnSettings))
}
}
.onChange(of: viewModel.status) { status in
switch status {
case .error:
dispatch(.root(.dismissAfterError))
case .warning:
dispatch(.root(.viewModelDidProduceWarning))
case .ready:
break
}
}
}
}
}

The actions can be from user’s interaction or ViewModel state changes

Handle the Actions

The coordinator can be easily understood. You receive a routing request from the view and this request comes with an action and eventually data associated. You need to handle the action to take a routing decision.

You can handle these requests inside the onNext(current path: ProfilePaths) as you can see below, or you can create an extension for your coordinator and implement the functions of requests.

For example: func requestFromProfileRootView(_ action: )

class ProfileCoordinator: Coordinator<ProfilePaths> {

override func onNext(current path: ProfilePaths) {
switch path {
case .root(let action):
switch action {
case .didTapOnSettings:
present(.settings(), mode: .sheet)
case .didTapOnEditBio:
present(.editBio(), mode: .push)
case .viewModelDidProduceWarning:
dismiss()
case .dismissAfterError:
present(.warning, mode: .fullScreen)
case .idle:
break
// view just appeared
}
default:
break
}
}

override func onAppear(context: ProfilePaths) {
super.onAppear(context: context)
}

override func onDisappear(context: ProfilePaths) {
super.onDisappear(context: context)
}

override func buildView(presenter: ContextPresenter<ProfilePaths>) -> AnyView {
switch presenter.name {
case .root:
let viewModel = ProfileViewModel()
return ProfileRootView(presenter: presenter, viewModel: viewModel).any
default:
fatalError("You need to implement the construction of every view for your paths")
}
}
}

As you can see, inside the coordinator we also have methods responsible to tell us which paths are currently presented or removed. The function buildView is where you gonna assemble your view and return it as AnyView. Jump has an extension .any that makes this construction easier. If you use Dependency Containers, here is a good place to inject it inside your ViewModels.

What to Improve / Open Points

  • Directly wrap the content inside the ContextContent without the need for the user to do it always.
  • Avoid type erasure for the content view ( Improve performance )
  • Evaluate the possibility to implement the navigation stack for iOS>16
  • Present in sequence
  • Evaluate the need to make Coordinators have parent/child relations with each other
  • Improve Documentation

Considerations

It’s highly recommended to understand if this coordinator can help your development. Recently Apple launched NavigationStack which facilitates much more navigation with SwiftUI. Take a look more here: NavigationStack

Links:

GitHub — Jump SwiftUI Coordinator

GitHub — Jump SwiftUI Coordinator — UITests

Good to Know

Open for collaborations and feedback

--

--