Coordinators in UIKit: An In-Depth Overview
Coordinators are an integral part of UIKit, a framework that defines the core components and infrastructure for building graphical, event-driven applications in iOS. This article delves into the concept of Coordinators, discussing their importance, implementation, and providing code examples to illustrate their usage.
Importance of Coordinators
In UIKit development, managing the navigation flow and interactions between view controllers can become complex and cumbersome, especially in large applications. Coordinators address these challenges by encapsulating navigation logic, promoting a cleaner and more modular architecture. They decouple view controllers from navigation responsibilities, enhancing code readability, maintainability, and testability.
Implementation of Coordinators
Implementing coordinators involves creating a Coordinator protocol, which declares required properties and methods. A base Coordinator class can handle common tasks, and individual coordinators inherit from the base class to manage specific flows.
What We’ll Build
We’ll create an app with three screens:
- A user list
- User details view
- A name change view
1. Setting Up the Foundation
Let’s start by defining our Coordinator protocol:
protocol CoordinatorProtocol: AnyObject {
var navigationController: UINavigationController { get set }
var childCoordinators: [CoordinatorProtocol] { get set }
func start()
func finish()
}
extension CoordinatorProtocol {
func finish() {
childCoordinators.removeAll()
}
}Let’s break down the key components of this protocol:
navigationController: This is the UINavigationController that the coordinator will use to manage navigation. It's essential for pushing and popping view controllers.childCoordinators: This array holds references to child coordinators. It's crucial for managing complex navigation hierarchies and preventing memory leaks.start(): This method is called to initiate the coordinator's flow. It typically involves creating and presenting the initial view controller for the flow.finish(): This method is used to clean up the coordinator, typically removing all child coordinators. We provide a default implementation that clears thechildCoordinatorsarray.
Note: The AnyObject constraint ensures that only class types can conform to this protocol, which is necessary because coordinators need to be reference types to maintain their state across the app's lifetime.
2. Creating the App Coordinator
The AppCoordinator serves as the main coordinator of our application, it's responsible for setting up the initial navigation structure of the app. This setup provides several benefits:
- Clear separation of concerns: The AppCoordinator focuses solely on managing the app’s initial setup and main navigation flow
- Improved testability: We can easily provide mock navigation controllers for testing
- Flexibility: The creation and management of dependencies can be adapted based on the app’s complexity
- Maintainability: The initialization logic is centralized and can be modified without affecting other parts of the app
class AppCoordinator: CoordinatorProtocol {
var navigationController: UINavigationController
var childCoordinators: [CoordinatorProtocol] = []
init(navigationController: UINavigationController) {
self.navigationController = navigationController
}
func start() {
let userListCoordinator = UserListCoordinator(navigationController: navigationController)
childCoordinators.append(userListCoordinator)
userListCoordinator.start()
}
}Here’s what’s happening:
- In the
initmethod, we store the navigation controller that will be used throughout the app. This navigation controller typically comes from the app’s launch point (SceneDelegate in modern/simple apps, or AppDelegate in older ones), or in more complex applications, it might come from a dependency injection container or factory. - The
start()method creates and starts the UserListCoordinator, which will manage the first screen of our app. - We add the UserListCoordinator to the
childCoordinatorsarray to keep a strong reference to it and prevent it from being deallocated.
The AppCoordinator is instantiated, in this scenario, in the SceneDelegate (which we’ll see later) and serves as the root of our coordinator hierarchy.
3. Setting Up the Data Model
Let’s create a simple User model:
class User {
let id: Int
var name: String
init(id: Int, name: String) {
self.id = id
self.name = name
}
}If you already know SwiftUI, you're probably thinking why the model was created using a class instead of a struct. Here’s why:
- Classes are reference types, which means we’re not creating copies every time we pass a User object around. This can be more efficient, especially if we’re dealing with large amounts of data.
- It allows us to mutate the user’s properties (like the name) without needing to reassign the entire object.
In SwiftUI, it’s more common to use structs for models because of its reactive nature and value type semantics. However, in UIKit-based apps, using classes for models that need to be mutable can be more straightforward.
4. Implementing the User List Feature
UserListViewModel
class UserListViewModel {
var users: [User]
init(users: [User]) {
self.users = users
}
}UserListCoordinator
class UserListCoordinator: CoordinatorProtocol {
var navigationController: UINavigationController
var childCoordinators: [CoordinatorProtocol] = []
private var userListViewModel: UserListViewModel
init(navigationController: UINavigationController) {
self.navigationController = navigationController
self.userListViewModel = UserListViewModel(users: [
User(id: 1, name: "Luiz Mello"),
User(id: 2, name: "William Smith"),
User(id: 3, name: "Arthur Morgan")
])
}
func start() {
let viewController = UserListViewController(viewModel: userListViewModel)
viewController.coordinator = self
navigationController.pushViewController(viewController, animated: true)
}
func showUserDetail(for user: User) {
let userDetailCoordinator = UserDetailCoordinator(navigationController: navigationController, user: user)
childCoordinators.append(userDetailCoordinator)
userDetailCoordinator.start()
}
}In this UserListCoordinator:
- We create and initialize the UserListViewModel within the coordinator. This is a common approach when the data is simple or when the coordinator is responsible for fetching the initial data. In more complex apps, you might inject a data service or use dependency injection to provide the data.
- The
start()method creates the UserListViewController, sets its coordinator (to allow callbacks), and pushes it onto the navigation stack. - The
showUserDetail(for:)method demonstrates how coordinators handle navigation between different parts of the app. It creates a new UserDetailCoordinator, adds it to the childCoordinators array, and starts it.
5. Implementing User Details
UserDetailViewModel
class UserDetailViewModel {
var user: User
init(user: User) {
self.user = user
}
func updateUserName(_ newName: String) {
user.name = newName
}
}UserDetailCoordinator
class UserDetailCoordinator: CoordinatorProtocol {
var navigationController: UINavigationController
var childCoordinators: [CoordinatorProtocol] = []
let viewModel: UserDetailViewModel
init(navigationController: UINavigationController, user: User) {
self.navigationController = navigationController
self.viewModel = UserDetailViewModel(user: user)
}
func start() {
let viewController = UserDetailViewController(viewModel: viewModel)
viewController.coordinator = self
navigationController.pushViewController(viewController, animated: true)
}
func showChangeNameView() {
let changeNameVC = ChangeNameViewController(viewModel: viewModel)
changeNameVC.coordinator = self
navigationController.pushViewController(changeNameVC, animated: true)
}
func nameUpdated() {
navigationController.popViewController(animated: true)
if let userDetailVC = navigationController.topViewController as? UserDetailViewController {
userDetailVC.updateUI()
}
}
}In the UserDetailCoordinator:
- We set
changeNameVC.coordinator = selfto allow the ChangeNameViewController to communicate back to its coordinator. This creates a weak reference, preventing retain cycles. - In the
nameUpdated()method, we usenavigationController.topViewControllerto get the current top view controller after popping the ChangeNameViewController. We then cast it to UserDetailViewController and update its UI. This approach ensures that we're updating the correct view controller, even if the navigation stack has changed.
6. Integration with SceneDelegate
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
var appCoordinator: AppCoordinator?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = (scene as? UIWindowScene) else { return }
let navigationController = UINavigationController()
appCoordinator = AppCoordinator(navigationController: navigationController)
window = UIWindow(windowScene: windowScene)
window?.rootViewController = navigationController
window?.makeKeyAndVisible()
appCoordinator?.start()
}
}The SceneDelegate is responsible for setting up the initial structure of our app. Here, we create the main navigation controller, initialize the AppCoordinator with it, set the window’s root view controller, and start the AppCoordinator. This kickstarts our entire coordination process.
Key Benefits of Using Coordinators
- Separation of Concerns: By moving navigation logic out of view controllers, we make them more focused and easier to maintain. View controllers become responsible solely for managing their views, while coordinators handle the flow between different parts of the app.
- Reusability: Coordinators encapsulate navigation logic, making it easier to reuse across different parts of your app or even in different projects. This is particularly useful for common flows like authentication or onboarding.
- Testability: With coordinators, you can test navigation flow independently of view controllers. This makes it easier to write unit tests for your app’s navigation logic.
- Flexibility: Changing the app’s flow becomes much easier when navigation logic is centralized in coordinators. You can modify or replace entire flows without touching view controller code.
- Scalability: As your app grows, the Coordinator pattern helps manage complexity by providing a clear structure for adding new features and flows.
Best Practices
- Keep coordinators focused: Each coordinator should be responsible for a specific flow or feature in your app. Avoid creating “god” coordinators that manage too much.
- Maintain a clear hierarchy: Use child coordinators for sub-flows, and make sure to manage their lifecycle properly (adding and removing them as needed).
- Use dependency injection: Instead of creating dependencies inside coordinators, consider injecting them. This makes your coordinators more flexible and easier to test.
- Avoid retain cycles: Be careful when setting up relationships between coordinators and view controllers. Use weak references where appropriate to avoid memory leaks.
- Clean up resources: Implement the
finish()method in your coordinators to clean up any resources and remove child coordinators when a flow is complete. - Consider using a coordinator factory: For larger apps, you might want to create a factory to instantiate coordinators. This can help manage dependencies and make your code more modular.
What Can Be Added
While our implementation covers the basics, there are several enhancements you could consider:
- Deep linking: Implement a system to handle deep links, using coordinators to navigate to the appropriate part of your app.
- Tab bar coordination: Extend the pattern to work with UITabBarController for apps with multiple top-level flows.
- Dependency injection: Implement a more robust system for managing and injecting dependencies into your coordinators and view models.
- State restoration: Use coordinators to help manage state restoration, allowing your app to return to its previous state after being terminated.
- Analytics and logging: Implement a system for tracking navigation events through your coordinators, which can be useful for analytics and debugging.
Conclusion
The Coordinator pattern is a powerful tool for managing navigation and flow in iOS applications. By separating these concerns from your view controllers, you create a more modular, testable, and maintainable architecture.
In this guide, we’ve walked through a basic implementation of the Coordinator pattern, demonstrating how it can be used to manage a simple user management app. We’ve seen how coordinators can work together with view controllers and view models to create a clean, separation of concerns.
While the pattern does add some complexity to your app’s architecture, the benefits in terms of flexibility, reusability, and maintainability often outweigh this cost, especially as your app grows in size and complexity.
Remember, the Coordinator pattern is not a one-size-fits-all solution. It’s most beneficial in apps with complex navigation flows or those that require a high degree of flexibility in changing those flows. For simpler apps, it might be overkill. As with any architectural decision, consider your app’s specific needs and constraints when deciding whether to implement the Coordinator pattern.
As you continue to explore and implement this pattern, you’ll likely discover ways to adapt and enhance it to best suit your specific use cases. The key is to maintain the core principles of separation of concerns and centralized navigation management, while tailoring the implementation to your app’s unique requirements.
