SwiftUI View, UIKit Navigation

Uwais Alqadri
4 min readMay 28, 2023

SwiftUI provides a very clear way to build your UI, but UIKit does it better when it comes to navigation, what if I tell you there’s a way to combine the best part of each?

The idea is simple, we want our code to be completely written in SwiftUI but can do UIKit navigation like pushViewController, popViewController, present, dismiss, etc.

Please continue reading if your curiosity grows

Problem

Let’s define our problem here, what’s so bad about SwiftUI navigation anyway? you may ask

Too many variables for each navigation. this might be a “me problem” but it bothers me a lot, every time I want to present a view, each navigation has its own representation @State variable

struct CustomerPersonalInfoView: View {

@EnvironmentObject var viewModel: CustomerViewModel
@StateObject private var nameValidator = BasicFieldValidator()

// for presenting a sheet
@State private var isShowSourceIncome = false
@State private var isShowIncome = false
@State private var isShowPurpose = false

}

and it can be so overwhelming when working on a multi-form screen where the text fields are selectable bottom sheet, imagine a screen that has the form of country, province, city, district, etc. The more view to present the more @State you have to define.

When it comes to push and pop navigation, SwiftUI can also be overwhelming by how the navigation is attached to view components, this video has good information about it.

To be fair, they do update the navigation system by introducing NavigationStack which supports starting from iOS 16+

Most of the time you’ll more likely to make your own navigation system as the complexity increase.

What we actually need

Have you tried another declarative framework similar to SwiftUI? whether it’s Jetpack Compose or Flutter both share the same navigation concept, the navigator is used to define how to navigate to your screen, not how to present/show your view.

So instead of waiting years to finally be able to use NavigationStack, or make your own navigation system, it’s actually very achievable to use UIKit navigation which has been used since ancient times.

Wrap your SwiftUI Views inside UIViewController

Fortunately, Apple made SwiftUI very interoperable with UIKit, using such things as UIViewRepresentable and UIHostingController

To wrap our SwiftUI Views into UIViewController, we can use UIHostingController wrapper protocol called ViewControllable that conforms View

import SwiftUI

public class NavStackHolder {
public weak var viewController: UIViewController?

public init() {}
}

public protocol ViewControllable: View {
var holder: NavStackHolder { get set }

func loadView()
func viewOnAppear(viewController: UIViewController)
}

the NavStackHolder were used to store/hold UIViewController as a weak reference.

then let’s create an extension to make lifecycle-related protocol functions optional, and initialize the value for NavStackHolder

public extension ViewControllable {
var viewController: UIViewController {
let viewController = HostingController(rootView: self)
self.holder.viewController = viewController
return viewController
}

func loadView() {}
func viewOnAppear(viewController: UIViewController) {}
}

TheHostingController is a subclass of UIHostingController that is used to call lifecycle functions from a view controller

public class HostingController<ContentView>: UIHostingController<ContentView> where ContentView: ViewControllable {
public override func loadView() {
super.loadView()
self.rootView.loadView()
}

public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.rootView.viewOnAppear(viewController: self)
}

// call any function to override here...
}

In case you wondering is UIHostingController lifecycle any different than UIViewController please read the documentation here.

Implementation

It’s pretty much the same as the regular SwiftUI View, but instead of implementing View protocol, we use ViewControllable protocol

import SwiftUI

struct FirstView: ViewControllable {

var holder: NavStackHolder

var body: some View {
VStack(spacing: 30) {
Button(action: {
navigateToSecondView()
}) {
Text("FirstView: Push")
}

Button(action: {
presentSecondView()
}) {
Text("FirstView: Present")
}

Button(action: {
fullScreenSecondView()
}) {
Text("FirstView: Full Screen")
}
}
}

func navigateToSecondView() {
guard let viewController = holder.viewController else { return }
let view = SecondView(holder: NavStackHolder())
viewController.navigationController?.pushViewController(view.viewController, animated: true)
}

func presentSecondView() {
guard let viewController = holder.viewController else { return }
let view = SecondView(holder: NavStackHolder())
viewController.present(view.viewController, animated: true)
}

func fullScreenSecondView() {
guard let viewController = holder.viewController else { return }
let view = SecondView(holder: NavStackHolder())
let nextViewController = view.viewController
nextViewController.modalPresentationStyle = .fullScreen
nextViewController.modalTransitionStyle = .crossDissolve
viewController.present(nextViewController, animated: true)
}
}



struct SecondView: ViewControllable {
var holder: NavStackHolder

func viewOnAppear(viewController: UIViewController) {
// api calls...
}

var body: some View {
Button(action: {
holder.viewController?.dismiss(animated: true)
}) {
Text("SecondView: Tapped")
}
}
}

TADAA!!

It’s important to pass a NavStackHolder()instance for each view that corresponds to a ViewControllableso that it contains view controllers outside of the struct itself.

I’ve personally used this for medium-scale SwiftUI projects, and it’s basically fair to say you can use this for pretty much everything, whether it’s a SwiftUI TabView, Push, Present, Bottom Sheets, Custom Pop Up Dialog, or UIKit’s custom transition.

That’s all everyone, it’s me Uwais, Peace Out!!

--

--

Uwais Alqadri

A person who's curious about Mobile Technology and very passionate about it. specialize in Swift (Apple Platforms) and Kotlin (Android, Kotlin Multiplatform).