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. The library is developed during working hours for the Byva app.
π€
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 a 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? π§πΌβπ«
Example using a Navigation Stack:
class ProjectsCoordinator: NavigationCoordinatable {
var navigationStack = NavigationStack() // usually you would want to initialize this without any active children
enum Route {
case project(id: UUID)
case createProject
}
func resolveRoute(route: Route) -> Transition {
switch route {
case .project(let id):
return .push(AnyView(ProjectSummaryScreen(id: id)))
case .createProject:
return .modal(AnyCoordinatable(CreateProjectCoordinator()))
}
}
@ViewBuilder func start() -> some View {
ProjectsScreen()
}
}
The Route
-enum defines all the possible routes that can be performed from the current coordinator. The function resolve(route: Route)
is responsible for providing the transition and the actual view/coordinator that we will route to. This can be combined with a factory in the coordinator as well.
Using a router, which has a reference to the coordinator, we can perform transitions from a view - but also pop to the previous screen, dismissing the coordinator, switch tab etc (depending on the type of coordinator). Inside the view, the router can be fetched using @EnvironmentObject
.
struct ProjectsScreen: View {
@EnvironmentObject var projects: NavigationRouter<ProjectsCoordinator.Route>
var body: some View {
List {
/* ... */
}
.navigationBarItems(
trailing: Button(
action: { projects.route(to: .createProject) },
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
.
Stinsen out of the box has three different kinds of Coordinatable
protocols your coordinators can implement:
NavigationCoordinatable
- For navigational flows. Make sure to wrap these in a NavigationViewCoordinator somewhere if you wish to push on the navigation stack.TabCoordinatable
- For TabViews.ViewCoordinatable
- Just a view and routes that do not push but rather replace the entire view, can be used for instance when switching between logged in/logged out.
π₯
ViewModel Support Since @EnvironmentObject
only can be accessed within a View
, Stinsen provides two methods of passing the router to the ViewModel.
Via onAppear
struct ProjectsScreen: View {
@EnvironmentObject var projects: NavigationRouter<ProjectsCoordinator.Route>
var body: some View {
List {
/* ... */
}
.onAppear {
viewModel.router = projects
}
}
}
RouterObject
The RouterStore
saves the instance of the router and you can get it via a custom PropertyWrapper. This provides a nice decoupling between View and ViewModel.
To retrieve a router:
class LoginScreenViewModel: ObservableObject {
// directly via the RouterStore
var main: ViewRouter<MainCoordinator.Route>? = RouterStore.shared.retrieve()
// via the RouterObject property wrapper
@RouterObject
var unauthenticated: NavigationRouter<UnauthenticatedCoordinator.Route>?
init() {
}
func loginButtonPressed() {
main?.route(to: .authenticated)
}
func forgotPasswordButtonPressed() {
unauthenticated?.route(to: .forgotPassword)
}
}
πΎ
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
π±
Sample App
Clone the repo and run the StinsenApp 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.
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.
π
License Stinsen is released under an MIT license. See LICENCE for more information.