A lightweight, type-safe navigation framework for UIKit. Born from real production challenges at WINCH, AppRouter eliminates navigation boilerplate and enforces clean composition patterns.
Navigation in UIKit apps often looks like this:
let vc = ServiceLocationViewController()
let presenter = ServiceLocationPresenter(output: WeakRef(vc))
let dataSource = AppDataSource()
let useCase = ServiceLocationUseCase(dataSource: dataSource, output: presenter, selectedLocationType: type)
vc.startLocationUpdate = useCase.startLocationUpdate
vc.confirmLocateServiceAction = useCase.confirmLocateServiceAction
navigationController?.pushViewController(vc, animated: true)Composition logic scattered everywhere. Duplicate code. No consistency.
router?.navigate(to: .serviceLocation(params: ["selectedLocationType": type]))One line. All composition hidden behind a Route protocol.
// Package.swift
dependencies: [
.package(url: "https://github.com/salahamassi/AppRouter-UIKit.git", from: "1.0.9")
]// AppDelegate
var appRouter: AppRouter?
func application(_ application: UIApplication, didFinishLaunchingWithOptions...) -> Bool {
let nav = UINavigationController()
appRouter = AppRouter(window: window, rootViewController: nav)
return true
}extension UIViewController: Routable {
var router: AppRouter? { appRouter }
}class ProfileRoute: Route {
var navigateType: NavigateType { .push }
func create(_ router: AppRouter, _ params: [String: Any]?) -> UIViewController {
let userId = params?["userId"] as? String ?? ""
return ProfileViewController(userId: userId)
}
}router?.navigate(to: ProfileRoute(), with: ["userId": "123"])| Type | Description |
|---|---|
.push |
Push onto navigation stack |
.present |
Modal presentation |
.windowRoot |
Replace window root controller |
.addChild(parent, view:, safeArea:) |
Add as child with nested router |
Each screen's dependencies are composed in its Route — not scattered across the codebase:
class OrderDetailsRoute: Route {
var navigateType: NavigateType { .push }
func create(_ router: AppRouter, _ params: [String: Any]?) -> UIViewController {
guard let orderId = params?["orderId"] as? String else {
fatalError("OrderDetailsRoute requires orderId")
}
let loader = OrderDetailsLoader()
let presenter = OrderDetailsPresenter()
return OrderDetailsViewController(orderId: orderId, loader: loader, presenter: presenter)
}
}For simple screens without complex composition:
let route: RouteFactory<SettingsViewController> = RouteFactory.createRoute(navigateType: .push)
router?.navigate(to: route)Prevent the same screen from being pushed twice:
router?.canDuplicateViewControllers = falserouter?.popViewController() // Pop one
router?.pop(numberOfScreens: 2) // Pop multiple
router?.remove(types: [LoadingViewController.self]) // Remove specific types
router?.removeAllAndKeep(types: [HomeViewController.self]) // Keep only theseFor container patterns like tab bars with embedded navigation:
class OrdersRoute: Route {
let parent: UIViewController
let containerView: UIView
var navigateType: NavigateType {
.addChild(parent, view: containerView, safeArea: true)
}
func create(_ router: AppRouter, _ params: [String: Any]?) -> UIViewController {
UINavigationController(rootViewController: OrdersViewController())
}
}
// Child screens use innerRouter instead of the main router
innerRouter?.navigate(to: .orderDetails(params: ["orderId": order.id]))class FadeRoute: Route {
var navigateType: NavigateType { .push }
var transition: CATransition? {
let t = CATransition()
t.type = .fade
return t
}
func create(_ router: AppRouter, _ params: [String: Any]?) -> UIViewController {
FadeViewController()
}
}class PhotoPreviewRoute: Route {
private let animator = PhotosPresentDismissAnimator()
var navigateType: NavigateType { .present }
var modalPresentationStyle: UIModalPresentationStyle { .custom }
var animatedTransitioningDelegate: UIViewControllerTransitioningDelegate? { animator }
func create(_ router: AppRouter, _ params: [String: Any]?) -> UIViewController {
PhotoPreviewViewController(medias: params?["medias"] as? [Media] ?? [])
}
}Use enums with Swift extensions for clean route access:
enum AuthRoutes {
case signin
case signup
case pinCode(mobile: String)
}
extension AppRouter {
func navigate(to route: AuthRoutes) {
switch route {
case .signin:
navigate(to: SigninRoute())
case .signup:
navigate(to: SignupRoute())
case .pinCode(let mobile):
navigate(to: PinCodeRoute(), with: ["mobile": mobile])
}
}
}
// Usage
router?.navigate(to: .signin)
router?.navigate(to: .pinCode(mobile: "+966501234567"))24 tests covering all navigation scenarios:
Executed 24 tests, with 0 failures in 7.6 seconds
- Zero dependencies — Pure UIKit, no third-party frameworks
- Production-tested — Powers WINCH's logistics platform serving Gulf clients
- Lightweight — Simple protocol-based API, easy to adopt incrementally
- Testable — Router can be mocked for unit testing navigation logic
Salah Nahed — Senior Mobile Engineer
MIT