Demonstrates a cleaner programmatic navigation approach with Coordinator pattern in UIKit application with MVVM pattern(MVVM with Coordinator known as MVVM-C)
Organized navigation makes the overall code cleaner, more readable, and more organized. Talk is cheap, so let's get into the code.
This project manages the following view with a single Coordinator (AppCoordinator). In large-scale applications, multi-level Coordinators can be maintained like we maintained in a single Coordinator application.
Demo simulation:
Simulator.Screen.Recording.-.iPhone.15.Pro.-.2024-03-30.at.15.14.25.mp4
The Coordinator protocol
Let's build a basic Coordinator blue-print which maintains all basic navigation actions.
protocol Coordinator {
associatedtype Destination
var navigationController: UINavigationController { get }
func pushViewController(_ viewController: UIViewController)
func presentViewController(_ viewController: UIViewController)
func showAlert(title: String, message: String)
func dismiss()
func popViewController()
func start(with destination: Destination)
}
extension Coordinator {
func pushViewController(_ viewController: UIViewController) {
navigationController.pushViewController(viewController, animated: true)
}
func presentViewController(_ viewController: UIViewController) {
let presentedNavigationController = UINavigationController(rootViewController: viewController)
navigationController.present(presentedNavigationController, animated: true)
}
func showAlert(title: String, message: String) {
let alert = UIAlertController(
title: title,
message: message,
preferredStyle: .alert
)
alert.addAction(.init(title: "OK", style: .default))
navigationController.present(alert, animated: true)
}
func dismiss() {
navigationController.dismiss(animated: true)
}
func popViewController() {
navigationController.popViewController(animated: true)
}
}
AppCoordinator conforming to Coordinator
class AppCoordinator: Coordinator {
let navigationController = UINavigationController()
// AppCoordinator deals with only single Coordinator started from `SceneDelegate`.
// In complex applications if there are multilevel hierarchy of views
// then Destination will deal with multiple destination cases.
// From different places different `destination` might be started
enum Destination {
case rootView
}
func start(with destination: Destination = .rootView) {
switch destination {
case .rootView:
pushViewController(mainViewController)
}
}
}
From the SceneDelegate
which is the entry point we start the AppCoordinator
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = (scene as? UIWindowScene) else { return }
window = UIWindow(windowScene: windowScene)
window?.rootViewController = appCoordinator.navigationController
window?.makeKeyAndVisible()
appCoordinator.start() // ---> Here it is
}
AppCoordinator handles MainView navigation events
Main View handles 3 navigation events
- Push Child View
- Present Child View
- Present Alert (Yes, it is also a kind of navigation, because navigationController presents it)
// MARK: Handle MainView events
extension AppCoordinator {
private func handleEvent(_ event: MainViewModel.Event) {
switch event {
case .pushChildViewTapped:
pushViewController(childViewController(presentationType: .pushed))
case .presentChildViewTapped:
presentViewController(childViewController(presentationType: .presented))
case .showAlertTapped:
showAlert(title: "Dummy Alert", message: "Dummy message for alert")
}
}
// Creates and returns MainViewController
var mainViewController: MainViewController {
.makeViewController(
with: .init(handleEvent: handleEvent)
)
}
}
AppCoordinator handles ChildView navigation events
Our ChildView could be shown either as Pushed view or as Presented view. Based on this presentatioType
there could be 3 possible navigation events.
- Done button pressed (in Presented view)
- Cancel button pressed (in Presented view)
- Back button pressed (in Pushed view)
// MARK: Handle ChildView events
extension AppCoordinator {
private func handleEvent(_ event: ChildViewModel.Event) {
switch event {
case .doneButtonTapped, .cancelButtonTapped:
dismiss()
case .backButtonTapped:
popViewController()
}
}
// Creates ChildViewController given the presentationType
private func childViewController(presentationType: ChildViewModel.PresentationType) -> ChildViewController {
.makeViewController(with: .init(
presentationType: presentationType,
handleEvent: handleEvent
))
}
}
Views and ViewModels
Now the Views (ViewControllers) and ViewModels are created to cope up with the Coordinator with these thumb rule.
- User interacts with the ViewController
- ViewController notifies about the interaction to the ViewModel
- ViewModel notifies the associated Coordinator to handle user interaction in case of navigation event with the
handleEvent(ViewModel.Event)
closure
MainViewController
class MainViewController: UIViewController {
var viewModel: MainViewModel!
static func makeViewController(with viewModel: MainViewModel) -> MainViewController {
let viewController = UIStoryboard(name: "MainStoryboard",bundle: nil)
.instantiateViewController(withIdentifier: "MainViewController") as! MainViewController
viewController.viewModel = viewModel
return viewController
}
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.title = viewModel.navigationTitle
}
@IBAction func pushChildViewTapped(_ sender: Any) {
viewModel.pushChildViewTapped()
}
@IBAction func presentChildViewTapped(_ sender: Any) {
viewModel.presentChildViewTapped()
}
@IBAction func showAlertTapped(_ sender: Any) {
viewModel.showAlertTapped()
}
}
MainViewModel
class MainViewModel {
enum Event {
case pushChildViewTapped
case presentChildViewTapped
case showAlertTapped
}
let navigationTitle = "Main View"
let handleEvent: (Event) -> Void // --> This closure is sent from the associated Coordinator during initialization
init(handleEvent: @escaping (Event) -> Void) {
self.handleEvent = handleEvent
}
func pushChildViewTapped() {
handleEvent(.pushChildViewTapped)
}
func presentChildViewTapped() {
handleEvent(.presentChildViewTapped)
}
func showAlertTapped() {
handleEvent(.showAlertTapped)
}
}
ChildViewController
class ChildViewController: UIViewController {
var viewModel : ChildViewModel!
@IBOutlet weak var label: UILabel!
static func makeViewController(with viewModel: ChildViewModel) -> ChildViewController {
let viewController = UIStoryboard(name: "ChildViewStoryboard",bundle: nil)
.instantiateViewController(withIdentifier: "ChildViewController") as! ChildViewController
viewController.viewModel = viewModel
return viewController
}
override func viewDidLoad() {
super.viewDidLoad()
label.text = viewModel.viewTitle
setupNavigationItems()
}
private func setupNavigationItems() {
navigationItem.title = viewModel.viewTitle
switch viewModel.presentationType {
case .pushed:
let backButton = UIBarButtonItem(title: "Back", style: .plain, target: self, action: #selector(backButtonAction))
navigationItem.leftBarButtonItem = backButton
case .presented:
// Contains Done button and Cancel button in presented view
let doneButton = UIBarButtonItem(title: "Done", style: .done, target: self, action: #selector(doneButtonAction))
let cancelButton = UIBarButtonItem(title: "Cancel", style: .done, target: self, action: #selector(cancelButtonAction))
navigationItem.rightBarButtonItem = doneButton
navigationItem.leftBarButtonItem = cancelButton
}
}
@objc private func doneButtonAction() {
viewModel.doneButtonTapped()
}
@objc private func cancelButtonAction() {
viewModel.cancelButtonTapped()
}
@objc private func backButtonAction() {
viewModel.backButtonTapped()
}
}
MainViewModel
class ChildViewModel {
enum PresentationType {
case pushed
case presented
}
enum Event {
case doneButtonTapped
case cancelButtonTapped
case backButtonTapped
}
let viewTitle = "Child View"
let handleEvent: (Event) -> Void // --> This closure is sent from the associated Coordinator during initialization
let presentationType: PresentationType
init(presentationType: PresentationType, handleEvent: @escaping (Event) -> Void) {
self.handleEvent = handleEvent
self.presentationType = presentationType
}
func doneButtonTapped() {
handleEvent(.doneButtonTapped)
}
func cancelButtonTapped() {
handleEvent(.cancelButtonTapped)
}
func backButtonTapped() {
handleEvent(.backButtonTapped)
}
}
Conclusion A clean navigation management is a must for readable and testable code. This is a basic blueprint of Coordinator based programatic navigation in UIKit based iOS Application with MVVM design pattern, often named as MVVM-C. For larger view hierarchy multiple and multilevel Coordinators also can be joined with this.