/stinsen

Coordinators in SwiftUI. Simple, powerful and elegant.

Primary LanguageSwiftMIT LicenseMIT

Stinsen

Language Platform License

Simple, powerful and elegant implementation of the Coordinator pattern in SwiftUI. Stinsen is written using 100% SwiftUI which makes it work seamlessly across iOS, tvOS, watchOS and macOS devices.

Why? πŸ€”

We all know routing in UIKit can be hard to do elegantly when working with applications of a larger size or when attempting to apply an architectural pattern such as MVVM. Unfortunately, SwiftUI out of the box suffers from many of the same problems as UIKit does: concepts such as NavigationLink live in the view-layer, we still have no clear concept of flows and routes, and so on. Stinsen was created to alleviate these pains, and is an implementation of the Coordinator Pattern. Being written in SwiftUI, it is completely cross-platform and uses the native tools such as @EnvironmentObject. The goal is to make Stinsen feel like a missing tool in SwiftUI, conforming to its coding style and general principles.

What is a Coordinator? πŸ€·πŸ½β€β™‚οΈ

Normally in SwiftUI, the view has to handle adding other views to the navigation stack using NavigationLink. What we have here is a tight coupling between the views, since the view must know in advance all the other views that it can navigate between. Also, the view is in violation of the single-responsibility principle (SRP). Using the Coordinator Pattern, presented to the iOS community by Soroush Khanlou at the NSSpain conference in 2015, we can delegate this responsibility to a higher class: The Coordinator.

How do I use Stinsen? πŸ‘©πŸΌβ€πŸ«

Defining the coordinator

Example using a Navigation Stack:

final class UnauthenticatedCoordinator: NavigationCoordinatable {
    let stack = NavigationStack(initial: \UnauthenticatedCoordinator.start)
    
    @Root var start = makeStart
    @Route(.modal) var forgotPassword = makeForgotPassword
    @Route(.push) var registration = makeRegistration
    
    func makeRegistration() -> RegistrationCoordinator {
        return RegistrationCoordinator()
    }
    
    @ViewBuilder func makeForgotPassword() -> some View {
        ForgotPasswordScreen()
    }
    
    @ViewBuilder func makeStart() -> some View {
        LoginScreen()
    }
}

The @Routes defines all the possible routes that can be performed from the current coordinator and the transition that will be performed. The value on the right hand side is the factory function that will be executed when routing. The function can return either a SwiftUI view or another coordinator. The @Root another type of route that has no transition, and used for defining the first view of the coordinator's navigation stack, which is referenced by the NavigationStack-class.

Stinsen out of the box has two different kinds of Coordinatable protocols your coordinators can implement:

  • NavigationCoordinatable - For navigational flows. Make sure to wrap these in a NavigationViewCoordinator if you wish to push on the navigation stack.
  • TabCoordinatable - For TabViews.

In addition, Stinsen also has two Coordinators you can use, ViewWrapperCoordinator and NavigationViewCoordinator. ViewWrapperCoordinator is a coordinator you can either subclass or use right away to wrap your coordinator in a view, and NavigationViewCoordinator is a ViewWrapperCoordinator subclass that wraps your coordinator in a NavigationView.

Showing the coordinator for the user

The view for the coordinator can be created using .view(), so in order to show a coordinator to the user you would just do something like:

struct StinsenApp: App {
    var body: some Scene {
        WindowGroup {
            MainCoordinator().view()
        }
    }
}

Stinsen can be used to power your whole app, or just parts of your app. You can still use the usual SwiftUI NavigationLinks and present modal sheets inside views managed by Stinsen, if you wish to do so.

Navigating from the coordinator

Using a router, which has a reference to both the coordinator and the view, we can perform transitions from a view. Inside the view, the router can be fetched using @EnvironmentObject. Using the router one can transition to other routes:

struct TodosScreen: View {
    @EnvironmentObject var todosRouter: TodosCoordinator.Router
    
    var body: some View {
        List {
          /* ... */
        }
        .navigationBarItems(
            trailing: Button(
                action: {
                    // Transition to the screen to create a todo:
                    todosRouter.route(to: \.createTodo) 
                },
                label: { 
                    Image(systemName: "doc.badge.plus") 
                }
            )
        )
    }
}

You can also fetch routers referencing coordinators that appeared earlier in the tree. For instance, you may want to switch the tab from a view that is inside the TabView.

Routing can be performed directly on the coordinator itself, which can be useful if you want your coordinator to have some logic, or if you pass the coordinator around:

final class MainCoordinator: NavigationCoordinatable {
    @Root var unauthenticated = makeUnauthenticated
    @Root var authenticated = makeAuthenticated
    
    /* ... */
    
    init() {
        /* ... */

        cancellable = AuthenticationService.shared.status.sink { [weak self] status in
            switch status {
            case .authenticated(let user):
                self?.root(\.authentiated, user)
            case .unauthenticated:
                self?.root(\.unauthentiated)
            }
        }
    }
}

What actions you can perform from the router/coordinator depends on the kind of coordinator used. For instance, using a NavigationCoordinatable, some of the functions you can perform are:

  • popLast - Removes the last item from the stack. Note that Stinsen doesn't care if the view was presented modally or pushed, the same function is used for both.
  • pop - Removes the view from the stack. This function can only be performed by a router, since only the router knows about which view you're trying to pop.
  • popToRoot - Clears the stack.
  • root - Changes the root (i.e. the first view of the stack). If the root is already the active root, will do nothing.
  • route - Navigates to another route.
  • focusFirst - Finds the specified route if it exists in the stack, starting from the first item. If found, will remove everything after that.
  • dismissCoordinator - Deletes the whole coordinator and it's associated children from the tree.

Examples πŸ“±

Stinsen Sample App

Clone the repo and run the StinsenApp in Examples/App to get a feel for how Stinsen can be used. StinsenApp works on iOS, tvOS, watchOS and macOS. It attempts to showcase many of the features Stinsen has available for you to use. Most of the code from this readme comes from the sample app. There is also an example showing how Stinsen can be used to apply a testable MVVM-C architecture in SwiftUI, which is available in Example/MVVM.

Advanced usage πŸ‘©πŸΎβ€πŸ”¬

ViewModel Support

Since @EnvironmentObject only can be accessed within a View, Stinsen provides a couple of ways of routing from the ViewModel. You can inject the coordinator through the Γ¬nitializer, or register it at creation and resolve it in the viewmodel through a dependency injection framework. These are the recommended ways of doing this, since you will have maximum control and functionality.

Other ways are passing the router using the onAppear function:

struct TodosScreen: View {
    @StateObject var viewModel = TodosViewModel() 
    @EnvironmentObject var projects: TodosCoordinator.Router
    
    var body: some View {
        List {
          /* ... */
        }
        .onAppear {
            viewModel.router = projects
        }
    }
}

You can also use what is called the RouterStore to retreive the router. The RouterStore saves the instance of the router and you can get it via a custom PropertyWrapper.

To retrieve a router:

class LoginScreenViewModel: ObservableObject {
    
    // directly via the RouterStore
    var main: MainCoordinator.Router? = RouterStore.shared.retrieve()
    
    // via the RouterObject property wrapper
    @RouterObject
    var unauthenticated: Unauthenticated.Router?
    
    init() {
        
    }
    
    func loginButtonPressed() {
        main?.root(\.authenticated)
    }
    
    func forgotPasswordButtonPressed() {
        unauthenticated?.route(to: \.forgotPassword)
    }
}

To see this example in action, please check the MVVM-app in Examples/MVVM.

Customizing

Sometimes you'd want to customize the view generated by your coordinator. NavigationCoordinatable and TabCoordinatable have a customize-function you can implement in order to do so:

final class AuthenticatedCoordinator: TabCoordinatable {
    /* ... */
    @ViewBuilder func customize(_ view: AnyView) -> some View {
        view
            .onReceive(Services.shared.$authentication) { authentication in
                switch authentication {
                case .authenticated:
                    self.root(\.authenticated)
                case .unauthenticated:
                    self.root(\.unauthenticated)
                }
            }
        }
    }
}

There is also a ViewWrapperCoordinator you can use to customize as well.

Chaining

Since most functions on the coordinator/router return a coordinator, you can use the results and chain them together to perform more advanced routing, if needed. For instance, to create a SwiftUI buttons that will change the tab and select a specific todo from anywhere in the app after login:

VStack {
    ForEach(todosStore.favorites) { todo in
        Button(todo.name) {
            authenticatedRouter
                .focusFirst(\.todos)
                .child
                .popToRoot()
                .route(to: \.todo, todo.id)
        }
    }
}

The AuthenticatedCoordinator referenced by the authenticatedRouter is a TabCoordinatable, so the function will:

  • focusFirst: return the first tab represented by the route todos and make it the active tab, unless it already is the active one.
  • child: will return it's child, the Todos-tab is a NavigationViewCoordinator and the child is the NavigationCoordinatable.
  • popToRoot: will pop away any children that may or may not have been present.
  • route: will route to the route Todo with the specified id.

Since Stinsen uses KeyPaths to represent the routes, the functions are type-safe and invalid chains cannot be created. This means: if you have a route in A to B and in B to C, the app will not compile if you try to route from A to C without routing to B first. Also, you cannot perform actions such as popToRoot() on a TabCoordinatable and so on.

Deep Linking

Using the returned values, you can easily deeplink within the app:

final class MainCoordinator: NavigationCoordinatable {
    @ViewBuilder func customize(_ view: AnyView) -> some View {
        view.onOpenURL { url in
            if let coordinator = self.hasRoot(\.authenticated) {
                do {
                    // Create a DeepLink-enum
                    let deepLink = try DeepLink(url: url, todosStore: coordinator.todosStore)
                    
                    switch deepLink {
                    case .todo(let id):
                        coordinator
                            .focusFirst(\.todos)
                            .child
                            .route(to: \.todo, id)
                    }
                } catch {
                    print(error.localizedDescription)
                }
            }
        }
    }
}

Creating your own Coordinatable

Stinsen comes with a couple of Coordinatables for standard SwiftUI views. If you for instance want to use it for a Hamburger-menu, you need to create your own. Check the source-code to get some inspiration.

Installation πŸ’Ύ

Stinsen supports two ways of installation, Cocoapods and SPM.

SPM

Open Xcode and your project, click File / Swift Packages / Add package dependency... . In the textfield "Enter package repository URL", write https://github.com/rundfunk47/stinsen and press Next twice

Cocoapods

Create a Podfile in your app's root directory. Add

# Podfile
use_frameworks!

target 'YOUR_TARGET_NAME' do
    pod 'Stinsen'
end

Known issues and bugs πŸ›

  • Stinsen does not support DoubleColumnNavigationViewStyle. The reason for this is that it does not work as expected due to issues with isActive in SwiftUI. Workaround: Use UIViewRepresentable or create your own implementation.
  • Stinsen works pretty bad in various older versions of iOS 13 due to, well, iOS 13 not really being that good at SwiftUI. Rather than trying to set a minimum version that Stinsen supports, you're on your own if you're supporting iOS 13 to figure out whether or not the features you use actually work. Generally, version 13.4 and above seem to work alright.

Who are responsible? πŸ™‹πŸΏβ€β™‚οΈ

At Byva we strive to create a 100% SwiftUI application, so it is natural that we needed to create a coordinator framework that satisfied this and other needs we have. The framework is used in production and manages ~50 flows and ~100 screens. The framework is maintained by @rundfunk47.

Why the name "Stinsen"? πŸš‚

Stins is short in Swedish for "Station Master", and Stinsen is the definite article, "The Station Master". Colloquially the term was mostly used to refer to the Train Dispatcher, who is responsible for routing the trains. The logo is based on a wooden statue of a stins that is located near the train station in LinkΓΆping, Sweden.

Updating from Stinsen v1 πŸš€

The biggest change in Stinsen v2 is that it is more type-safe than Stinsen v1, which allows for easier chaining and deep-linking, among other things.

  • The Route-enum has been replaced with property wrappers.
  • AnyCoordinatable has been replaced with a protocol. It does not perform the same duties as the old AnyCoordinatable and does not fit in with the more type-safe routing of version 2, so remove it from your project.
  • Enums are not used for routes, now Stinsen uses keypaths. So instead of route(to: .a) we use route(to: \.a).
  • CoordinatorView has been removed, use .view().
  • Routers are specialized using the coordinator instead of the route.
  • Minor changes to functions and variable names.
  • Coordinators need to be marked as final.
  • ViewCoordinatable has been removed and folded into NavigationCoordinatable. Use multiple @Roots and switch between them using .root() to get the same functionality.

License πŸ“ƒ

Stinsen is released under an MIT license. See LICENCE for more information.