Categories
iOS Swift SwiftUI UIKit

UIKit navigation with SwiftUI views

Recently I was asked a question about creating an app which has SwiftUI views but no navigation logic in it. Instead, UIKit controls how views are presented. It is a fair question since SwiftUI views have navigation support, but not everything is there if we need to support previous iOS versions as well, or we have a case of an app which have both UIKit and SwiftUI views. Therefore, let’s take a look at on one approach, how to handle navigation on the UIKit side but still use SwiftUI views.

UIHostingController presenting SwiftUI view

SwiftUI views are presented in UIKit views with UIHostingController which just takes in the SwiftUI view. UIHostingController is a UIViewController subclass, therefore it can be used like any other view controller in the view hierarchy. For getting things started, let’s configure SceneDelegate to use an object named FlowCoordinator which will handle navigation logic and then ask it to present a simple SwiftUI view.

final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private lazy var flowController = FlowCoordinator(window: window!)
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
window = UIWindow(windowScene: windowScene)
flowController.showRootView()
window?.makeKeyAndVisible()
}
}
final class FlowCoordinator {
private let window: UIWindow
init(window: UIWindow) {
self.window = window
}
func showRootView() {
let swiftUIView = ContentView()
let hostingView = UIHostingController(rootView: swiftUIView)
window.rootViewController = UINavigationController(rootViewController: hostingView)
}
}
struct ContentView: View {
var body: some View {
VStack {
Text("Hello, World!")
}.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.green)
}
}
A simple iOS app showing a green view which is rendered with SwiftUI but presented with UIKit
A simple app showing a green view which is rendered with SwiftUI but presented with UIKit.

Inserting the FlowCoordinator into SwiftUI view

The next step is that we want to allow SwiftUI view to control what is presented on the screen. For example, let’s add a button to the SwiftUI view which should present a sheet with another SwiftUI view. The button action needs to be able to talk to the flow coordinator, which controls what is presented on the screen. One way to insert the FlowCoordinator into SwiftUI environment is by conforming to ObservableObject and using the environmentObject() view modifier. Alternative is using EnvironmentValues and defining a key. For more information please check Injecting dependencies using environment values and keys in SwiftUI.

final class FlowCoordinator: ObservableObject {}
let swiftUIView = ContentView()
.environmentObject(self) // self is FlowCoordinator
struct ContentView: View {
@EnvironmentObject var flowController: FlowCoordinator
var body: some View {
VStack(spacing: 8) {
Text("Root view")
Button("Present Sheet", action: flowController.showDetailView)

Presenting a sheet

The sheet presentation code goes into the FlowCoordinator and as an example we show a DetailView which has a button for dismissing itself. Yet again, SwiftUI view just passes the handling to the FlowCoordinator.

final class FlowCoordinator: ObservableObject {
//
func showDetailView() {
let detailView = DetailView()
.environmentObject(self)
let viewController = UIHostingController(rootView: detailView)
window.rootViewController?.present(viewController, animated: true, completion: nil)
}
func closeDetailView() {
// Needs to be more sophisticated later when there are more views
window.rootViewController?.presentedViewController?.dismiss(animated: true, completion: nil)
}
}
struct DetailView: View {
@EnvironmentObject var flowController: FlowCoordinator
var body: some View {
VStack(spacing: 8) {
Text("Detail view content")
Button("Dismiss", action: flowController.closeDetailView)
}.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.orange)
}
}
A simple iOS app showing an orange sheet which is rendered with SwiftUI but presented with UIKit
A simple app showing an orange sheet which is rendered with SwiftUI but presented with UIKit.

Summary

We created a simple sample app which uses UIKit navigation logic but renders views with SwiftUI views. This kind of setup could be useful for apps which mix UIKit and SwiftUI. But I believe that even in case of that we could still use SwiftUI navigation in sub-flows but could keep using this approach for handling root view navigation.

Example Project

UIKitNavigationWithSwiftUIViews (Xcode 13.2.1)

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

Categories
Combine iOS Swift SwiftUI

Animating view transitions in SwiftUI

One building block for navigating from one view to another is NavigationView which is a representation of UINavigationController in UIKit. This time, let’s take a look on how to transition from one SwiftUI view to another one without NavigationView.

AppFlowCoordinator managing choosing the view

The idea is to have a root SwiftUI view with only responsibility of presenting the active view. State is stored in AppFlowCoordinator which can be accessed from other views and therefore other views can trigger navigation. Example case we’ll build, is animating transitions from login view to main view and back. As said, AppFlowCoordinator stores the information about which view should be on-screen at a given moment. All the views are represented with an enum and based on the value in enum, views are created. This coordinator is ObservableObject what makes it easy to bind to a SwiftUI view – whenever activeFlow changes, SwiftUI view is updated. The term flow is used because views can consist of stack of other views and therefore creating a flow of views.

import SwiftUI
final class AppFlowCoordinator: ObservableObject {
@Published var activeFlow: Flow = .login
func showLoginView() {
withAnimation {
activeFlow = .login
}
}
func showMainView() {
withAnimation {
activeFlow = .main
}
}
}
extension AppFlowCoordinator {
enum Flow {
case login, main
}
}
Triggering navigation using flow controller.

RootView displaying active flow

RootView selects which view is currently visible. It accesses coordinator through environment. SwiftUI requires EnvironmentObjects to be ObservableObjects, therefore this view is automatically refreshed when activeFlow changes in the AppFlowCoordinator. RootView’s body is annotated with @ViewBuilder which will enable the view to return a body with type depending on the current state (HStack is also a ViewBuilder). Other options are wrapping the views with AnyView or using Group. In our case the view types are LoginView and ContentView. Both views also define the transition animation what is used when view refresh is triggered in withAnimation closure in AppFlowCoordinator. Asymmetric enables defining different transitions when view is added and removed from the view hierarchy.

let appFlowCoordinator = AppFlowCoordinator()
let rootView = RootView().environmentObject(appFlowCoordinator)
window.rootViewController = UIHostingController(rootView: rootView)
Inserting AppFlowCoordinator to environment
struct RootView: View {
@EnvironmentObject var appFlowCoordinator: AppFlowCoordinator
@ViewBuilder
var body: some View {
ZStack {
Color.black.edgesIgnoringSafeArea(.all)
if appFlowCoordinator.activeFlow == .main {
ContentView().transition(.asymmetric(insertion: .scale, removal: .opacity))
}
else if appFlowCoordinator.activeFlow == .login {
LoginView().transition(.asymmetric(insertion: .slide, removal: .opacity))
}
else {
EmptyView()
}
}
}
}
view raw RootView.swift hosted with ❤ by GitHub
Updating currently visible flow with transition animations

Triggering navigation from SwiftUI view

Last piece we need to take a look at is how to trigger transition. As AppFlowCoordinator is in environment, any view can access the coordinator and call any of the navigation methods. When login finishes, LoginView can tell the coordinator to show the main content view.

struct LoginView: View {
@EnvironmentObject var appFlowCoordinator: AppFlowCoordinator
var body: some View {
ZStack {
Button(action: appFlowCoordinator.showMainView) {
Text("Login")
}
}
}
}
view raw LoginView.swift hosted with ❤ by GitHub
Navigating to main view from login view

Summary

We took a look on how to navigate from one SwiftUI view to another by using a coordinator object. Coordinator stored the information about which view we should currently display on screen. We saw how easy it is to trigger navigation from any of the currently visible views.

transition animation
Low FPS GIF representing the transition animation

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

Example

RootViewTransitions Xcode 11.2.1, Swift 5.1

Categories
Generics iOS Swift UIKit

Navigating using flow controllers and responder chain on iOS

Every app consists of different flows for achieving a specific goal. For example, there is a sequence of views for sign up. When sign up flow ends, we need to move to so called main view what represents the main functionality of the app. There are definitely a lot of different ways how to handle app navigation and each of the approach have their own pros and cons. With this in mind, the approach I am going to demonstrate this time, is how to use flow controllers and using responder chain to connect flows to each other.

Navigating from flow to flow

Flow controller is a UIResponder coordinating a single flow in an app. It handles showing views, injecting dependencies and storing intermediate values required to pass from one view controller to another. Navigation from one flow to another happens using responder chain. This also enables us to use sending actions to nil first responder in story boards and xib files. Using responder chain adds extra flexibility when restructuring an app or changing flows a lot. There is only a little code needed to set up new flows. It is a kind of lightweight approach to coordinator pattern where delegation is replaced with responder chain.

Setting up protocols

As a first step we define two protocols: one for defining entry points and the second one for accessing flow controllers from any responder in responder chain. Flow controllers are going to conform to specialised presenting protocol and responders (typically view controller), which trigger navigation, conform to one of the controlling protocols. FlowPresenting protocol is going to define a single function what is used for presenting a first view in the flow. I find examples to be the best way of learning new approaches, therefore let’s build a sample app with three flows: app, login and main flow.

protocol FlowControlling {}
protocol FlowPresenting {
func showInitialView()
}

Controlling app flow

AppFlowController is the controller handling presenting new flows. It conforms to AppFlowPresenting protocol what defines entry points to all the different flows. In addition, it handles inserting active flow to responder chain which enables easy access to flow controller from any presented view controller. Finding a flow controller from responder chain is implemented using a generic function. This enables implementing new getters for other flow controllers with just one line.

protocol AppFlowControlling: FlowControlling {
var appFlowController: AppFlowPresenting { get }
}
protocol AppFlowPresenting: FlowPresenting {
func showMainView()
}
extension AppFlowControlling where Self: UIResponder {
var appFlowController: AppFlowPresenting {
return flowController()
}
}
extension UIResponder {
func flowController<T>() -> T {
var current: UIResponder? = self
repeat {
if let presenter = current as? T {
return presenter
}
current = current?.next
} while current != nil
fatalError()
}
}

In the current sample app, AppFlowController is going to handle representing all of the flows in the app. This is because all the current flows are consisting of one branch in a so called tree of flows. If we would have a more complex application, other flow controllers would handle their own subset of flows and would insert those flows into responder chain (like AppFlowController is inserting LoginFlowController and MainFlowController into responder chain). This kind of architecture allows creating a tree like structure of flows and separating them from each other – there is not going to be a single controller handling all the flows.

final class AppFlowController: UIResponder, AppFlowPresenting {
private let dependencyManager: DependencyManager
private let window: UIWindow
init(window: UIWindow, dependencyManager: DependencyManager) {
self.dependencyManager = dependencyManager
self.window = window
}
// MARK: Presenting Flows
func showInitialView() {
let controller = LoginFlowController(window: window)
controller.showInitialView()
activeFlow = controller
}
func showMainView() {
let controller = MainFlowController(window: window, dependencyManager: dependencyManager)
controller.showInitialView()
activeFlow = controller
}
// MARK: Managing the Responder Chain
var activeFlow: FlowPresenting? = nil
override var next: UIResponder? {
return activeFlow as? UIResponder
}
}

In the example app we do not use storyboard for initialising the first view (“Main Interface” field in target settings is empty). Instead, we set up a window ourself and use AppFlowController for presenting the first view. In addition, we inject a manager storing a set of dependencies view controllers might require. Having approach like this, we do not need singletons and instead, flow controllers insert dependencies into view controllers. Using dependency injection keeps the overall dependency graph nice and clean.

final class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
private var appFlowController: AppFlowController?
private let dependencyManager = DependencyManager()
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = UINavigationController()
self.window = window
appFlowController = AppFlowController(window: window, dependencyManager: dependencyManager)
appFlowController?.showInitialView()
window.makeKeyAndVisible()
return true
}
override var next: UIResponder? {
return appFlowController
}
}

Creating login flow controller

LoginFlowController has similar set up as AppFlowController with exception of not inserting a subsequent flow into responder chain. In the sample app, login flow does not branch into several other login related flows. At the end of login flow, AppFlowController is used to present main content view. Due to generic function we added to UIResponder extension, login flow controller accessor consists of a single line.

final class LoginFlowController: UIResponder, LoginFlowPresenting {
let window: UIWindow
init(window: UIWindow) {
self.window = window
}
func showInitialView() {
let viewController = UIStoryboard.main.instantiateViewController(withIdentifier: "login")
(window.rootViewController as? UINavigationController)?.setViewControllers([viewController], animated: false)
}
func showSignUp() {
let viewController = SignUpViewController()
(window.rootViewController as? UINavigationController)?.pushViewController(viewController, animated: true)
}
func showAccountDetails() {
let viewController = UIStoryboard.main.instantiateViewController(withIdentifier: "accountdetails")
(window.rootViewController as? UINavigationController)?.pushViewController(viewController, animated: true)
}
}
protocol LoginFlowPresenting: FlowPresenting {
func showAccountDetails()
func showSignUp()
}
protocol LoginFlowControlling: FlowControlling {
var loginFlowController: LoginFlowPresenting { get }
}
extension LoginFlowControlling where Self: UIResponder {
var loginFlowController: LoginFlowPresenting {
return flowController()
}
}

Let’s see how view controllers in the login flow trigger navigation. LoginViewController conforms to LoginFlowControlling. This makes loginFlowController accessor available and we can use it for triggering navigation. It should be noted that loginFlowController accessor returns an object conforming to LoginFlowPresenting and does not explicitly declare the type of LoginFlowController. This means that LoginViewController does not know about LoginFlowController, instead, it just knows that the returned object implements methods listed in LoginFlowPresenting protocol. Less coupling makes it easier to test and restructure app in the future.
Another important point to note here is that it is so easy to add navigation capability to any other view controller. Flow controller does not need to be injected and in the end, we just need to make two changes in the whole view controller – protocol extension takes care of adding getter to the interface and triggering navigation is just a single line of code.

final class LoginViewController: UIViewController, LoginFlowControlling {
@IBAction func createAccount(_ sender: Any) {
loginFlowController.showSignUp()
}
}

Navigating from login to main view

AccountDetailsViewController is the last view controller in the login flow and it should trigger navigation to the main flow. As seen in previous paragraph, triggering navigation requires only two changes. This is also the case here.

final class AccountDetailsViewController: UIViewController, AppFlowControlling {
@IBAction func goToFirst(_ sender: Any) {
appFlowController.showMainView()
}
}

Summary

This time we took a look on how to use flow controllers and responder chain to easily manage navigation from one view to another. In the end we implemented a scalable architecture where adding navigation trigger points just require a few changes. Scalability comes from the fact that flows can be arranged into tree like structure and there is no requirement to have a single object managing all the flows. Moreover, we added a dependency injection capability to flow controllers which make it so much easier to test components separately and not worrying about singletons.

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

Example Project

FlowController (GitHub) Xcode 10.1, Swift 4.2.1

References

Using Responders and the Responder Chain to Handle Events (Apple)
Generics (Swift)
Tree (data structure) (Wikipedia)