RIBs + SwiftUI. UIKit Navigation with SwiftUI Views via UIHostingController.
RIBs is one of the best client side architectures and as any good architecture it can be used with any UI framework. This example shows how to use SwiftUI via UIHostingController with UIKit navigation
Recently I made a LinkedIn post which seem to strike a cord with a lot of people:
https://www.linkedin.com/posts/alexvbush_ios-iosdev-swift-activity-7249899465065521152-QrPA
The basic premise is that SwiftUI is still (and always will be?) lacking a good navigation solution for your overall application but is good for individual views/screens.
The best architectures give you the most flexibility allowing you to choose the best library you want/need for the job at hand. It seems like I am not alone (judging by comments) in choosing not to build a pure “SwiftUI app” and instead reserve to having UI navigation in my apps be done via UIKit and the views be implemented with SwiftUI or UIView depending on the need.
Parallel with this conversation happening on LinkedIn, I stumbled upon an issue in Uber RIBs’ Github repo asking about SwiftUI support in RIBs. I lurk around RIBs repo and look at the questions and conversations occasionally as I still to this day can’t find a better client side architecture than RIBs (!!! but that’s a conversation for another day).
So I figured I’d kill two birds with one stone
and make a sample app with RIBs architecture where UIKit is used for navigation and SwiftUI for some of the views.
You can find it here: https://github.com/The-Mobile-Engineer/RIBs-SwiftUI-Example (btw, new github org I just created for sharing code samples on this substack!)
RIBs Architecture
I will not dwell into the details of what RIBs architecture is and how it works, but you can find more about it in other resources I published:
RIBs Architecture Overview
The RIBs video course I never got around to finish https://alexbush.podia.com/ribs-architecture-on-ios (shame on me :( I’ll finish it someday!)
What I will tell you is the main stuff to pay attention to in this codebase in regards of how to have UIKit navigation + SwiftUI views.
Code Overview
Overall it’s a pretty simple sample app. Root RIB starts the app and then decides to route to Login RIB or Main RIB based on whether the user is logged in. All RIBs have respective views/screens.
After Login RIB reports to Root RIB about successful login Root RIB then routes away from Login RIB first and then routes to the Main RIB. The Main RIB in this sample represents the main logged-in user experience.
The actual UI navigation within the Root to Login or Main happens inside RootViewController when the RootRouter asks it to either embed or modally present one of those RIBs’ views:
final class RootViewController: UIViewController, RootPresentable, RootViewControllable {
weak var listener: RootPresentableListener?
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .yellow
}
func showMainView(_ mainViewController: MainViewControllable) {
present(mainViewController.uiviewController, animated: true)
}
func removeMainView(_ mainViewController: MainViewControllable) {
mainViewController.uiviewController.dismiss(animated: true)
}
func embedLoginView(_ loginViewController: LoginViewControllable) {
embed(childViewController: loginViewController.uiviewController)
}
func removeLoginView(_ loginViewController: LoginViewControllable) {
removeChildViewController(loginViewController.uiviewController)
}
private func embed(childViewController: UIViewController) {
addChild(childViewController)
view.addSubview(childViewController.view)
childViewController.didMove(toParent: self)
if let childView = childViewController.view {
childView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
childView.topAnchor.constraint(equalTo: self.view.topAnchor),
childView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor),
childView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
childView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor)
])
}
}
private func removeChildViewController(_ childViewController: UIViewController) {
childViewController.willMove(toParent: nil)
childViewController.view.removeFromSuperview()
childViewController.removeFromParent()
}
}
Nothing exciting here, just plain old containment API provided by UIKit and a modal presentation. Btw, there is no real need to modally present the Main RIB, it actually should be an embed just like Login RIB, but I wanted to make it more interesting and to make a point that the actual navigating mechanics don’t matter that much.
SwiftUI UIHostingViewController Embed
Now onto the part you have all been waiting for! If you look at LoginViewController implementation you’ll see how we can have it be navigated to (and its own navigation be done) via UIKit but with the SwiftUI view being embedded into it using UIHostingViewController:
protocol LoginPresentableListener: AnyObject {
func login(withEmail email: String, password: String)
}
final class LoginViewController: UIViewController, LoginPresentable, LoginViewControllable, LoginViewDelegate {
weak var listener: LoginPresentableListener?
private lazy var observableObject = LoginViewObservableObject(delegate: self)
override func viewDidLoad() {
super.viewDidLoad()
setupView(view: LoginView(observableObject: observableObject))
}
private func setupView<Content>(view: Content) where Content : View {
let contentVC = UIHostingController<Content>(rootView: view)
self.addChild(contentVC)
contentVC.view.translatesAutoresizingMaskIntoConstraints = false
self.view.addSubview(contentVC.view)
contentVC.didMove(toParent: self)
NSLayoutConstraint.activate([
contentVC.view.topAnchor.constraint(equalTo: self.view.topAnchor),
contentVC.view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor),
contentVC.view.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
contentVC.view.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
])
}
func didTapLogin() {
listener?.login(withEmail: observableObject.email, password: observableObject.password)
}
}
As you can see there is nothing special about it either. We are using containment API again but this time to embed a UIHostingViewController
with LoginView
as its root view into LoginViewController
.
We also have a LoginViewObservableObject
as a passthrough delegate for view inputs and as a “holder” of the viewmodel/ui-data state that we’d want to have displayed in the SwiftUI view.
The LoginView SwiftUI view implementation looks like this:
import SwiftUI
protocol LoginViewDelegate: AnyObject {
func didTapLogin()
}
final class LoginViewObservableObject: ObservableObject {
private weak var delegate: LoginViewDelegate?
@Published var email: String = ""
@Published var password: String = ""
init(delegate: LoginViewDelegate? = nil) {
self.delegate = delegate
}
func onLoginTapped() {
delegate?.didTapLogin()
}
}
struct LoginView: View {
@ObservedObject private var observableObject: LoginViewObservableObject
init(observableObject: LoginViewObservableObject) {
self.observableObject = observableObject
}
var body: some View {
VStack {
TextField("Email", text: $observableObject.email)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()
.autocapitalization(.none)
.keyboardType(.emailAddress)
SecureField("Password", text: $observableObject.password)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()
Button(action: {
observableObject.onLoginTapped()
}) {
Text("Login")
.font(.headline)
.foregroundColor(.white)
.padding()
.frame(maxWidth: .infinity)
.background(Color.blue)
.cornerRadius(10)
.padding()
}
}
.padding()
}
}
As mentioned before, the Observable object is just a pass through but it plays an important role in allowing us to update the view or get user input. (In this example we don’t drive view updates from the business logic, this would be a topic of another article.)
After the user taps the login button, the signal/input gets passed to the observable, then to LoginViewcontroller
, then to LoginInteractor
, and finally RootInteractor
asks its router to route away from Login RIB and route to the Main RIB. But all of that is really just the RIBs architecture logic, the main point is that we are able to navigate via UIKit but use SwiftUI for the actual screen view!
Conclusion
Here it is, an example of how navigation should be handled in an iOS application using UIKit’s embeds, presentation, push, etc. where the screen views could use SwiftUI view or UIKit views, your choice.