The Coordinator pattern is a widely used design pattern in Swift/iOS applications that facilitates the management of navigation and view flow within an app. The main idea behind this pattern is to decouple the navigation logic from the views, thereby making it easier to maintain and extend the application over time. By offering a central point of contact for navigation purposes, the Coordinator pattern encapsulates the navigation logic and enables views to remain lightweight and focused on their own responsibilities.
This package provides a seamless integration of the Coordinator pattern into the SwiftUI framework, making it easy to implement and manage navigation in your SwiftUI applications. With the Coordinator pattern, you can easily manage the flow of views within your app, while maintaining a clear separation of concerns between views and navigation logic. This results in a more maintainable and extensible app, with clean and easy-to-understand code.
Despite the benefits of using SwiftUI, navigating between views and managing their flow can become a complex and cumbersome task. With NavigationStack
, there are limitations where dismissing or replacing views in the middle of the stack becomes challenging. This can occur when you have multiple views that are presented in sequence, and you need to dismiss or replace one of the intermediate views.
The second challenge is related to popping to the root view when you have several views presented in a hierarchical manner, and you want to return to the root view.
Coordinator protocol is the core component of the pattern representing each distinct flow of views in your app.
Protocol declaration
@MainActor
public protocol Coordinator: AnyObject {
/// A property that stores a reference to the parent coordinator, if any.
/// Should be used as a weak reference.
var parent: Coordinator? { get }
/// An array that stores references to any child coordinators.
var childCoordinators: [WeakCoordinator] { get set }
/// Takes action parameter and handles the `CoordinatorAction`.
func handle(_ action: CoordinatorAction)
/// Adds child coordinator to the list.
func add(child: Coordinator)
/// Removes the coordinator from the list of children.
func remove(coordinator: Coordinator)
}
This protocol defines the available actions for the coordinator. Views should exclusively interact with the coordinator through actions, ensuring a unidirectional flow of communication.
Protocol declaration
public protocol CoordinatorAction {}
public enum Action: CoordinatorAction {
/// Indicates a successful completion with an associated value.
case done(Any)
/// Indicates cancellation with an associated value.
case cancel(Any)
}
This protocol defines the available routes for navigation within a coordinator flow.
Protocol declaration
@MainActor
public protocol NavigationRoute {
/// Use this title to set the navigation bar title when the route is displayed.
var title: String? { get }
/// A property that provides the info about the appearance and styling of a route in the navigation system.
var appearance: RouteAppearance? { get }
/// Transition action to be used when the route is shown.
/// This can be a push action, a modal presentation, or `nil` (for child coordinators).
var action: TransitionAction? { get }
/// A property that indicates whether the Coordinator should be attached to the View as an EnvironmentObject.
var attachCoordinator: Bool { get }
/// A property that hides the back button during navigation
var hidesBackButton: Bool? { get }
/// A property that hides the navigation bar
var hidesNavigationBar: Bool? { get }
}
The Navigator protocol encapsulates all the necessary logic for navigating hierarchical content, including the management of the NavigationController
and its child views.
Protocol declaration
@MainActor
public protocol Navigator: ObservableObject {
associatedtype Route: NavigationRoute
var navigationController: NavigationController { get }
/// The starting route of the navigator.
var startRoute: Route { get }
/// This method should be called to start the flow and to show the view for the `startRoute`.
func start() throws
/// It creates a view for the route and adds it to the navigation stack.
func show(route: Route) throws
/// Creates views for routes, and replaces the navigation stack with the specified views.
func set(routes: [Route], animated: Bool)
/// Creates views for routes, and appends them on the navigation stack.
func append(routes: [Route], animated: Bool)
/// Pops the top view from the navigation stack.
func pop(animated: Bool)
/// Pops all the views on the stack except the root view.
func popToRoot(animated: Bool)
/// Dismisses the view.
func dismiss(animated: Bool)
}
The TabBarCoordinator
protocol provides a way to manage a tab bar interface in your application.
It defines the necessary properties and methods for handling tab bar navigation.
Protocol declaration
@MainActor
public protocol TabBarCoordinator: ObservableObject {
associatedtype Route: TabBarNavigationRoute
associatedtype TabBarController: UITabBarController
var navigationController: NavigationController { get }
/// The tab bar controller that manages the tab bar interface.
var tabBarController: TabBarController { get }
/// The tabs available in the tab bar interface, represented by `Route` types.
var tabs: [Route] { get }
/// This method should be called to show the `tabBarController`.
///
/// - Parameter action:The type of transition can be customized by providing a `TransitionAction`.
func start(with action: TransitionAction)
}
iOS 15.0
or higher
dependencies: [
.package(url: "https://github.com/erikdrobne/SwiftUICoordinator")
]
import SwiftUICoordinator
Start by creating an enum with all the available routes for a particular coordinator flow.
enum ShapesRoute: NavigationRoute {
case shapes
case simpleShapes
case customShapes
case featuredShape
var title: String? {
switch self {
case .shapes:
return "SwiftUI Shapes"
default:
return nil
}
}
var action: TransitionAction? {
switch self {
case .simpleShapes:
// We have to pass nil for the route presenting a child coordinator.
return nil
default:
return .push(animated: true)
}
}
}
Specify custom actions that can be sent from coordinated objects to their parent coordinators.
enum ShapesAction: CoordinatorAction {
case simpleShapes
case customShapes
case featuredShape(NavigationRoute)
}
The coordinator has to conform to the Routing
protocol and implement the handle(_ action: CoordinatorAction)
method which executes flow-specific logic when the action is received.
class ShapesCoordinator: Routing {
// MARK: - Internal properties
weak var parent: Coordinator?
var childCoordinators = [WeakCoordinator]()
let navigationController: NavigationController
let startRoute: ShapesRoute
let factory: CoordinatorFactory
// MARK: - Initialization
init(
parent: Coordinator?,
navigationController: NavigationController,
startRoute: ShapesRoute = .shapes,
factory: CoordinatorFactory
) {
self.parent = parent
self.navigationController = navigationController
self.startRoute = startRoute
self.factory = factory
}
func handle(_ action: CoordinatorAction) {
switch action {
case ShapesAction.simpleShapes:
let coordinator = factory.makeSimpleShapesCoordinator(parent: self)
try? coordinator.start()
case ShapesAction.customShapes:
let coordinator = factory.makeCustomShapesCoordinator(parent: self)
try? coordinator.start()
case let ShapesAction.featuredShape(route):
switch route {
...
default:
return
}
case Action.done(_):
popToRoot()
childCoordinators.removeAll()
default:
parent?.handle(action)
}
}
}
By conforming to the RouterViewFactory
protocol, we are defining which view should be displayed for each route.
Important: When we want to display a child coordinator, we should return an EmptyView.
extension ShapesCoordinator: RouterViewFactory {
@ViewBuilder
public func view(for route: ShapesRoute) -> some View {
switch route {
case .shapes:
ShapeListView<ShapesCoordinator>()
case .simpleShapes:
EmptyView()
case .customShapes:
CustomShapesView<CustomShapesCoordinator>()
case .featuredShape:
EmptyView()
}
}
}
We will instantiate AppCoordinator
(a subclass of RootCoordinator
), pass ShapesCoordinator
as its child, and then initiate the flow.
Our starting route will be ShapesRoute.shapes
.
final class SceneDelegate: NSObject, UIWindowSceneDelegate {
var dependencyContainer = DependencyContainer()
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let window = (scene as? UIWindowScene)?.windows.first else {
return
}
let appCoordinator = dependencyContainer.makeAppCoordinator(window: window)
dependencyContainer.set(appCoordinator)
let coordinator = dependencyContainer.makeShapesCoordinator(parent: appCoordinator)
appCoordinator.start(with: coordinator)
}
}
The coordinator is by default attached to the SwiftUI as an @EnvironmentObject
.
To disable this feature, you need to set the attachCoordinator
property of the NavigationRoute
to false
.
struct ShapeListView<Coordinator: Routing>: View {
@EnvironmentObject var coordinator: Coordinator
@StateObject var viewModel = ViewModel<Coordinator>()
var body: some View {
List {
Button {
viewModel.didTapBuiltIn()
} label: {
Text("Simple")
}
Button {
viewModel.didTapCustom()
} label: {
Text("Custom")
}
Button {
viewModel.didTapFeatured()
} label: {
Text("Featured")
}
}
.onAppear {
viewModel.coordinator = coordinator
}
}
}
SwiftUICoordinator also supports creating custom transitions.
class FadeTransition: NSObject, Transitionable {
func isEligible(
from fromRoute: NavigationRoute,
to toRoute: NavigationRoute,
operation: NavigationOperation
) -> Bool {
return (fromRoute as? CustomShapesRoute == .customShapes && toRoute as? CustomShapesRoute == .star)
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.3
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard let toView = transitionContext.view(forKey: .to) else {
transitionContext.completeTransition(false)
return
}
let containerView = transitionContext.containerView
toView.alpha = 0.0
containerView.addSubview(toView)
UIView.animate(withDuration: transitionDuration(using: transitionContext), animations: {
toView.alpha = 1.0
}, completion: { _ in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
}
}
Transitions will be registered by creating the NavigationControllerDelegateProxy
and passing them as parameters.
let factory = NavigationControllerFactory()
lazy var delegate = factory.makeNavigationDelegate([FadeTransition()])
lazy var navigationController = factory.makeNavigationController(delegate: delegate)
Custom modal transitions can enhance the user experience by providing a unique way to present
and dismiss
view controllers.
First, define a transition delegate object that conforms to the UIViewControllerTransitioningDelegate
protocol.
final class SlideTransitionDelegate: NSObject, UIViewControllerTransitioningDelegate {
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return SlideTransition(isPresenting: true)
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return SlideTransition(isPresenting: false)
}
}
In this example, SlideTransition
is a custom class that conforms to the UIViewControllerAnimatedTransitioning
protocol and handles the actual animation logic.
Pass the SlideTransitionDelegate
instance to the specific action where you wish to apply your modal transition.
var action: TransitionAction? {
switch self {
case .rect:
return .present(delegate: SlideTransitionDelegate())
default:
return .push(animated: true)
}
}
In your application, you can handle deep links by creating a DeepLinkHandler
that conforms to the DeepLinkHandling
protocol. This handler will specify the URL scheme and the supported deep links that your app can recognize.
class DeepLinkHandler: DeepLinkHandling {
static let shared = DeepLinkHandler()
let scheme = "coordinatorexample"
let links: Set<DeepLink> = [
DeepLink(action: "custom", route: ShapesRoute.customShapes)
]
private init() {}
}
To handle incoming deep links in your app, you can implement the scene(_:openURLContexts:)
method in your scene delegate.
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
guard
let url = URLContexts.first?.url,
let deepLink = try? dependencyContainer.deepLinkHandler.link(for: url),
let params = try? dependencyContainer.deepLinkHandler.params(for: url, and: deepLink.params)
else {
return
}
dependencyContainer.appCoordinator?.handle(deepLink, with: params)
}
For better understanding, I recommend that you take a look at the example project located in the SwiftUICoordinatorExample
folder.
Contributions are welcome to help improve and grow this project!
If you come across a bug, kindly open an issue on GitHub, providing a detailed description of the problem. Include the following information:
- steps to reproduce the bug
- expected behavior
- actual behavior
- environment details (Swift version, etc.)
For feature requests, please open an issue on GitHub. Clearly describe the new functionality you'd like to see and provide any relevant details or use cases.
To submit a pull request:
- Fork the repository.
- Create a new branch for your changes.
- Make your changes and test thoroughly.
- Open a pull request, clearly describing the changes you've made.
Thank you for contributing to SwiftUICoordinator! π
If you appreciate this project, kindly give it a βοΈ to help others discover the repository.