Skip to content

Repository files navigation

AppRouter-UIKit

CI Swift iOS SPM

A lightweight, type-safe navigation framework for UIKit. Born from real production challenges at WINCH, AppRouter eliminates navigation boilerplate and enforces clean composition patterns.

image

Installation

// Package.swift
dependencies: [
    .package(url: "https://github.com/salahamassi/AppRouter-UIKit.git", from: "1.0.9")
]

Quick Start

1. Set up the router

// AppDelegate
var appRouter: AppRouter?

func application(_ application: UIApplication, didFinishLaunchingWithOptions...) -> Bool {
    let nav = UINavigationController()
    appRouter = AppRouter(window: window, rootViewController: nav)
    return true
}

2. Make view controllers routable

extension UIViewController: Routable {
    var router: AppRouter? { appRouter }
}

3. Create routes

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)
    }
}

4. Navigate

router?.navigate(to: ProfileRoute(), with: ["userId": "123"])

Navigation Types

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

Features

Type-Safe Route Composition

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)
    }
}

Quick Routes with RouteFactory

For simple screens without complex composition:

let route: RouteFactory<SettingsViewController> = RouteFactory.createRoute(navigateType: .push)
router?.navigate(to: route)

Duplicate Prevention

Prevent the same screen from being pushed twice:

router?.canDuplicateViewControllers = false

Stack Management

router?.popViewController()                          // Pop one
router?.pop(numberOfScreens: 2)                      // Pop multiple
router?.remove(types: [LoadingViewController.self])   // Remove specific types
router?.removeAllAndKeep(types: [HomeViewController.self])  // Keep only these

Nested (Inner) Routers

For 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]))

Custom Transitions

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()
    }
}

Custom Presentation

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] ?? [])
    }
}

Organizing Routes

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"))

Testing

24 tests covering all navigation scenarios:

Executed 24 tests, with 0 failures in 7.6 seconds

The Problem

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.

The Solution

router?.navigate(to: .serviceLocation(params: ["selectedLocationType": type]))

One line. All composition hidden behind a Route protocol.

Why AppRouter?

  • 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

Author

Salah Nahed — Senior Mobile Engineer

License

MIT

About

A Swift library for managing navigation and screen composition in UIKit iOS applications

Topics

Resources

Stars

3 stars

Watchers

2 watching

Forks

Releases

Packages

Used by

Contributors

Languages