Jalal Al-Awqati

4 min read

Feb 12, 2023

Introduction

In the previous article, we outlined the drawbacks of SwiftUI navigation, and how it differs from UIKit, in that for views to navigate, Navigation Links had to be used, crowding the view logic with navigation logic. We then looked at how we can hide the navigation details from the view, and handle view creation in isolation, through the use of Router and Routable protocols.

struct ViewA: View {
let router: AnyRouter<Route>
var body: some View {
NavigationLink("ViewB") {
router.route(to: .viewB)
}
}
}

extension ViewA {
enum Route {
case viewB
}
}

final class ViewARouter: Router, ViewARoutable {
func root(dependency: Dependency) -> AnyView {
AnyView(ViewA(dependency: dependency))
}

func route(to route: HomeView.Route) -> AnyView {
switch route {
case .viewB:
return AnyView(ViewB())
}
}
}

While the above is certainly an improvement, it still lacks true isolation. Our view still knows that, at some point, it might navigate to a route. It is true that this route is hidden behind a Router, but it is still there. Meaning a future change in the navigation requirements would cause a change in the view. But if we can only use Navigation Links to navigate from one view to another, how could we completely remove the navigation details from the view?

Navigation with UIKit

SwiftUI offers great interoperability with UIKit, through UIViewRepresentable, UIViewControllerRepresentable, and UIHostingController. This helps us to wrap each of our SwiftUI views inside a UIHostingController, then do the navigation on these view controllers, without relying on Navigation Links. Meaning we can finally get rid of any navigation logic inside our views, and have them responsible only for presentation.

Consider this simple diagram:

Press enter or click to view image in full size

User input flows from the View to the View Model. If the View Model doesn’t know how to handle such input (navigation), it calls its delegate, which is usually the Router. The Router then performs the necessary navigation.

Let’s put this into code.

First, let’s extend UINavigationController to include convenience methods for pushing and presenting SwiftUI views

extension UINavigationController {
public func push<V: View>(_ view: V, animated: Bool = true) {
let viewController = UIHostingController(rootView: view)
pushViewController(viewController, animated: animated)
}

public func present<V: View>(_ view: V,
animated: Bool = true,
completion: (() -> Void)? = nil) {
let viewController = UIHostingController(rootView: view)
present(viewController, animated: animated, completion: completion)
}
}

Next, let’s create a view model for our view, which is responsible for handling user input. Each input that triggers a navigation is passed on to the view model’s delegate

protocol ViewAViewModelDelegate: AnyObject {
func didTapNavigateButton()
}

final class ViewAViewModel: ObservableObject {
weak var delegate: ViewAViewModelDelegate?

func didTapNavigateButton() {
delegate?.didTapNavigateButton()
}
}

The view would then simply be reduced to the following

struct ViewA: View {
@StateObject var viewModel: ViewAViewModel

var body: some View {
Button("Navigate") {
viewModel.didTapNavigateButton()
}
}
}

The button Navigate is no longer a NavigationLink, and doesn’t know anything about the existence of a Router. It is now a normal button, which calls a function on the view model when tapped.

Get Jalal Al-Awqati’s stories in your inbox

Join Medium for free to get updates from this writer.

Now that we have our view and view model, we need to modify our Router

protocol ViewARoutable {
func root() -> UINavigationController
}

final class ViewARouter: ViewARoutable {
private var navigationController: UINavigationController?

private let viewBRoutable: ViewBRoutable

init(viewBRoutable: ViewBRoutable) {
self.viewBRoutable = viewBRoutable
}

func root() -> UINavigationController {
let viewModel = ViewAViewModel()
let view = ViewA(viewModel: viewModel)
let viewController = UIHostingController(rootView: view)
let navigationController = UINavigationController(rootViewController: viewController)
self.navigationController = navigationController

viewModel.delegate = self

return navigationController
}
}

Let’s break down our Router above. First, we maintain a reference to a UINavigationController, which will be used for pushing and presenting views when needed. Next, the initializer takes an instance of ViewBRoutable, which will be used to get the destination of ViewB.

After that, in the root function, we create our view model, assign its delegate to self, and inject it into ViewA. We also create a UINavigationController and set its rootViewController to a UIHostingController that wraps our ViewA. Finally we return the UINavigationController instance.

Conformance to ViewAViewModelDelegate is also straightforward:

extension ViewARouter: ViewAViewModelDelegate {
func didTapNavigateButton() {
let view = viewBRoutable
.root(navigationController: navigationController)
navigationController?.push(view)
}
}

We pass the instance of UINavigationController we created earlier to the next Router, which it could use for its own navigation. Then the actual navigation to ViewB is done through pushViewController(_:animated:) under the hood.

Testing

Our navigation logic can now also be tested in isolation

class ViewARouterTests: XCTestCase {
var sut: ViewARouter!

override func setUp() {
super.setUp()
sut = ViewARouter()
}

func test_root_shouldReturnViewAWrappedInNavigationController() {
XCTAssert(sut.root().topViewController is UIHostingController<ViewA>)
}

func test_didTap_shouldReturnNavigationController() {
let root = sut.root()
sut.didTapNavigateButton()
XCTAssert(root.topViewController is UIHostingController<ViewB>)
}
}

Coordinators

If we look above at our Routers, we can see that we’re able to achieve a flavor of MVVM+C in SwiftUI. Since we moved the navigation logic out of the view, and are no longer bound by using Navigation Links, our ViewBRouter can simply be a Coordinator

protocol ViewBCoordinating {
func start(on navigationController: UINavigationController?)
}

final class ViewBCoordinator: ViewBCoordinating {
private var navigationController: UINavigationController?

func start(on navigationController: UINavigationController?) {

navigationController?.pushViewController(viewController,
animated: true)
}
}

The usage of UINavigationController above is optional, and depends on the presented view requirements. Some views don’t need to be pushed onto a UINavigationController stack, but rather only presented as a sheet, the coordinators of these views can then be started on any type of UIViewController.

Conclusion

By wrapping our views inside UIHostingController, we free ourselves of the navigation burden that comes with SwiftUI. Responsibility becomes divided among three modules, each responsible for presentation, business logic, and navigation. Our views now express their intent clearly, and contain only UI-related details. Business requirement changes that are related to navigation now only affect the relevant navigation modules, which are easy to find, change, and test.