Skip to content

Instantly share code, notes, and snippets.

@Coder-ACJHP
Created March 10, 2026 11:10
Show Gist options
  • Select an option

  • Save Coder-ACJHP/5dd53c9b053c7354744add8f3518197e to your computer and use it in GitHub Desktop.

Select an option

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
// 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