Stinsen
Coordinators in SwiftUI. Simple, powerful and elegant.
Install / Use
/learn @rundfunk47/StinsenREADME
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 📱
<img src="./Images/stinsenapp-ios.gif" alt="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
Related Skills
node-connect
340.5kDiagnose OpenClaw node connection and pairing failures for Android, iOS, and macOS companion apps
frontend-design
84.2kCreate distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
openai-whisper-api
340.5kTranscribe audio via OpenAI Audio Transcriptions API (Whisper).
commit-push-pr
84.2kCommit, push, and open a PR
