SwiftUI View, UIKit Navigation
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 aViewControllable
so 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!!
Sample Repository: https://github.com/uwaisalqadri/SwiftUIViewUIKitNavigation