Created
March 10, 2026 11:10
-
-
Save Coder-ACJHP/5dd53c9b053c7354744add8f3518197e to your computer and use it in GitHub Desktop.
Router Pattern in Swift — Type-Safe Navigation with Route Enum & Centralized ViewController Factory
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // MARK: - Route | |
| // Defines all possible navigation destinations in the app as a type-safe enum. | |
| // Associated values allow passing parameters directly through the route (e.g. OTP code), | |
| // eliminating the need to pass data separately after navigation. | |
| enum Route { | |
| case login | |
| case otp(code: String) | |
| case home | |
| } | |
| // MARK: - RouterDelegate Protocol | |
| // Abstracts all navigation operations behind a protocol. | |
| // ViewControllers depend on this protocol instead of AppRouter directly, | |
| // making them testable and decoupled from the concrete router implementation. | |
| @MainActor | |
| protocol RouterDelegate: AnyObject { | |
| func navigate(to route: Route, animated: Bool) // Push | |
| func present(to route: Route, animated: Bool) // Modal present | |
| func pop(animated: Bool) | |
| func dismiss(animated: Bool) | |
| func makeViewController(baseRoute route: Route) -> UIViewController // Factory | |
| } | |
| // MARK: - AppRouter | |
| // Concrete implementation of RouterDelegate. | |
| // Owns the UINavigationController and centralizes all navigation logic. | |
| // No ViewController needs to know about UINavigationController directly. | |
| @MainActor | |
| class AppRouter: RouterDelegate { | |
| private let controller: UINavigationController | |
| init(controller: UINavigationController) { | |
| self.controller = controller | |
| } | |
| func navigate(to route: Route, animated: Bool) { | |
| let destinationVC = makeViewController(baseRoute: route) | |
| controller.pushViewController(destinationVC, animated: animated) | |
| } | |
| func pop(animated: Bool) { | |
| controller.popViewController(animated: animated) | |
| } | |
| func present(to route: Route, animated: Bool) { | |
| let destinationVC = makeViewController(baseRoute: route) | |
| // Wraps in a new NavigationController so the modal has its own navigation stack | |
| let nav = UINavigationController(rootViewController: destinationVC) | |
| controller.present(nav, animated: animated) | |
| } | |
| func dismiss(animated: Bool) { | |
| controller.dismiss(animated: animated) | |
| } | |
| // MARK: ViewController Factory | |
| // Single place responsible for creating ViewControllers and injecting the router. | |
| // Adding a new screen only requires a new Route case and an entry here. | |
| func makeViewController(baseRoute route: Route) -> UIViewController { | |
| switch route { | |
| case .login: | |
| return LoginViewController(router: self) | |
| case .otp(let code): | |
| return OTPViewController(code: code, router: self) // Data passed via Route enum | |
| case .home: | |
| return HomeViewController(router: self) | |
| } | |
| } | |
| } | |
| // MARK: - ViewControllers | |
| // Each VC receives the router via constructor injection. | |
| // They navigate by specifying a Route, never by instantiating other VCs directly. | |
| final class LoginViewController: UIViewController { | |
| private let router: AppRouter | |
| init(router: AppRouter) { | |
| self.router = router | |
| super.init(nibName: nil, bundle: nil) | |
| } | |
| required init?(coder: NSCoder) { | |
| fatalError("init(coder:) has not been implemented") | |
| } | |
| @objc | |
| func handleLogin() { | |
| router.navigate(to: .otp(code: "123456"), animated: true) | |
| } | |
| } | |
| final class OTPViewController: UIViewController { | |
| private let router: AppRouter | |
| private let code: String // Injected through Route.otp(code:) associated value | |
| init(code: String, router: AppRouter) { | |
| self.code = code | |
| self.router = router | |
| super.init(nibName: nil, bundle: nil) | |
| } | |
| required init?(coder: NSCoder) { | |
| fatalError("init(coder:) has not been implemented") | |
| } | |
| @objc | |
| func handleVerifyOTP() { | |
| router.navigate(to: .home, animated: true) | |
| } | |
| } | |
| final class HomeViewController: UIViewController { | |
| private let router: AppRouter | |
| init(router: AppRouter) { | |
| self.router = router | |
| super.init(nibName: nil, bundle: nil) | |
| } | |
| required init?(coder: NSCoder) { | |
| fatalError("init(coder:) has not been implemented") | |
| } | |
| } | |
| // MARK: - SceneDelegate (Entry Point) | |
| // AppRouter is created once here and kept alive for the scene's lifetime. | |
| // The NavigationController is passed into the router, then set as the window's rootViewController. | |
| class SceneDelegate: UIResponder, UIWindowSceneDelegate { | |
| var window: UIWindow? | |
| var appRouter: AppRouter? | |
| func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { | |
| guard let windowScene = (scene as? UIWindowScene) else { return } | |
| let navigationController = UINavigationController() | |
| // 1. Router is initialized with the NavigationController | |
| let router = AppRouter(controller: navigationController) | |
| self.appRouter = router // Retained here to stay alive for the scene's lifetime | |
| // 2. First screen is created and injected with the router via makeViewController | |
| router.navigate(to: .login, animated: false) | |
| // 3. NavigationController (already containing LoginVC) becomes the root | |
| let window = UIWindow(windowScene: windowScene) | |
| window.rootViewController = navigationController | |
| self.window = window | |
| window.makeKeyAndVisible() | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment