Unlocking the Power of UIKit Navigation in SwiftUI

Anandhakrishnan M
3 min readDec 13, 2023

I was creating a new project in swiftUI and the navigation part was a challenge, especially putting all the navigation in one place. After researching I found that most people use UIKit navigation there were also other materials in navigation but they all used navigation stack but needed support for iOS 14 so I decided to use the UIkit navigation pattern.

The concept is to wrap the view with a view controller and use the navigation functions we use in UIKit.

import SwiftUI

public protocol Router {

associatedtype V: View

@ViewBuilder
func view() -> V
}
import SwiftUI

enum AppRoute: Router {

case firstScreen
case secondScreen(number: Int)
case thirdScreen(number: Int)

@ViewBuilder
func view() -> some View {
switch self {
case .firstScreen:
ExampleView()
case .secondScreen(let number):
SecondExampleView(numb: .constant(number))
case .thirdScreen(let number):
ThirdExampleView(numb: .constant(number))
}
}
}

The AppRoute contains all the screens in the app it should follow the Router protocol to ensure that it has a view() that returns a view.

Next is the navigation

import SwiftUI

class AppNavigation {

static var shared = AppNavigation(startingRoute: .firstScreen)
let startingRoute: AppRoute

init(navigationController: UINavigationController = .init(), startingRoute: AppRoute) {
self.startingRoute = startingRoute
}

func startingViewController() -> UIViewController{
let view = startingRoute.view()
let navigationController: UINavigationController = .init()
let viewWithCoordinator = view.environmentObject(navigationController)
let viewController = UIHostingController(rootView: viewWithCoordinator)
navigationController.setViewControllers([viewController], animated: false)
return navigationController
}

func present(_ route: AppRoute, animated: Bool = true, source: UINavigationController) {
let view = route.view()
let destinationNavigationController: UINavigationController = .init()
let viewWithNavigator = view.environmentObject(destinationNavigationController)
let viewController = UIHostingController(rootView: viewWithNavigator)
destinationNavigationController.modalPresentationStyle = .fullScreen
destinationNavigationController.setViewControllers([viewController], animated: animated)
source.present(destinationNavigationController, animated: animated)
}

func navigate(_ route: AppRoute, animated: Bool = true, source: UINavigationController) {
let view = route.view()
let viewWithNavigator = view.environmentObject(source)
let viewController = UIHostingController(rootView: viewWithNavigator)
source.showCustomBackButton()
source.pushViewController(viewController, animated: animated)
}

func presentModally(_ route: AppRoute, animated: Bool = true, source: UINavigationController) {
let view = route.view()
let destinationNavigationController: UINavigationController = .init()
let viewWithNavigator = view.environmentObject(destinationNavigationController)
let viewController = UIHostingController(rootView: viewWithNavigator)
destinationNavigationController.modalPresentationStyle = .formSheet
destinationNavigationController.setViewControllers([viewController], animated: animated)
source.present(destinationNavigationController, animated: animated)
}
}

In these functions, we are creating a view controller from the View using UIHostingController before creating the view controller we will pass the navigation controller of that view controller as an environment object so we can access the Navigation controller from inside the view.

import UIKit

extension UINavigationController: ObservableObject {

func navigateTo(route: AppRoute) {
AppNavigation.shared.navigate(route, source: self)
}

func presentScreen(route: AppRoute) {
AppNavigation.shared.present(route, source: self)
}

func pop() {
popViewController(animated: true)
}

func popToRoot() {
popToRootViewController(animated: true)
}
}

I am adding the navigation in the navigation controller for easily accessing it in the view. UINavigationController conforms to the ObservableObject only then we can pass it as an environment object.

import SwiftUI

struct SecondExampleView: View {
@EnvironmentObject var navigator: UINavigationController
@Binding var numb: Int
var body: some View {
Form {
Text("screen number \(numb)")
Button("dismiss") {
navigator.dismiss(animated: true)
}
Button("next screen present") {
navigator.presentScreen(route: .secondScreen(number: numb + 1))
}
Button("next screen push") {
navigator.navigateTo(route: .thirdScreen(number: numb + 1))
}
Button("pop to root") {
navigator.popToRoot()
}
Button("pop") {
navigator.pop()
}
}
.navigationTitle("secondView")
}
}

This is an example view where we get the navigation controller as an Environment object and call the navigation function we need by passing in the destination screen.

--

--