When your SwiftUI app more then one screen you probably start fighting with some NavigationView issues. Few of them can be resolved with old good UINavigationController. With this article, I will show you how you can use UINavigationController inside Coordinator with SwiftUI screens.

With my app I must resolve this issues:

  • Hidden NavigationLink to resolve missing environment when using UIKit component
  • Challenging with a hide navigation bar
  • UISerachController in SwitUI doesn’t exist
  • Transparent navigation bar when trying large title on the second screen
  • One screen know about other screen representation
  • Immediately create next struct next View when creating NavigationLink

And I think there are a little bit more problems with the current NavigationView.

Let’s start with Coordinator

I miss Coordinator more and more when my app is growing, so I decide to kill two birds with one stone and add UINavigatinNController with Coordinator.

final class Coordinator {

    let navigationController: UINavigationController

    convenience init<T: View>(
        rootView: T,
        container: InjectionContainer = InjectionContainerKey.defaultValue,
        appStyle: AppStyleProtocol = AppStyleKey.defaultValue
    ) {
        self.init(container: container, appStyle: appStyle)
        self.update(rootView: rootView, animated: false)
    }

    init(
        container: InjectionContainer = InjectionContainerKey.defaultValue,
        appStyle: AppStyleProtocol = AppStyleKey.defaultValue
    ) {
        self.container = container
        self.appStyle = appStyle
        self.navigationController = .init()
    }

    // MARK: - Combine
    private var disposables = Set<AnyCancellable>()

    // MARK: - Environment properties
    private var container: InjectionContainer
    private var appStyle: AppStyleProtocol
}

Implement it like class is required only for disposables property, without Combine integration, you can implement it like a struct. One of my challenges, when I change it to UINavigationController, was missing environment properties on the next screens, so I decide it store InjectionContainer and AppStyleProtocol inside Coordinator. With a small extension, I can transform any View to UIHostingController and add all required environments.

private extension Coordinator {

    /// Transform SwiftUI View to UIKit UIView and add known environment properties
    /// - Parameter view: SwiftUI View
    func transform<T: View>(view: T) -> UIHostingController<AnyView> {
        UIHostingController(
            rootView: AnyView(
                view
                    .environment(\.injected, container)
                    .environment(\.style, appStyle)
            )
        )
    }
}

Hide any evidence about UINavigationController

With the second extension I enable push, pop, present, and update any swift View and hide UIKit dependency.

private extension Coordinator {
    func push<T: View>(view: T, animated: Bool = true) {
        navigationController
            .pushViewController(
                transform(view: view),
                animated: animated
            )
    }

    func pop(animated: Bool = true) {
        navigationController.popViewController(animated: animated)
    }

    func present<T: View>(
        view: T, animated: Bool = true,
        completion: (() -> Void)? = nil
    ) {
        navigationController
            .topViewController?
            .present(
                transform(view: view),
                animated: animated,
                completion: completion
        )
    }

    func update<T: View>(rootView: T, animated: Bool = true) {
        navigationController.setViewControllers(
            [transform(view: rootView)],
            animated: animated
        )
    }
}

Example with push ExampleScreen

In my case is this extension private because I decide to extend Coordinator with *ViewModelPresenting protocol, but when you working with ReSwift your Coordinator can handle some action and then open a new screen or you can use closures or call it directly, it’s only in your hands.

In my way, I add ExampleScreen and one more extension to show red and green screens to test Coordinator implementation.

struct ExampleScreen: View {
    let title: String
    let color: Color
    let action: () -> Void

    var body: some View {
        Button(action: action) {
            Text(title)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(color)
    }
}

extension Coordinator {
    func showRedScreen() {
        let redScreen = ExampleScreen(
            title: "red",
            color: .red,
            action: { self.pop() }
        )
        push(view: redScreen)
    }

    func showGreenScreen() {
        let greenScreen = ExampleScreen(
            title: "green",
            color: .green,
            action: showRedScreen
        )
        update(rootView: greenScreen)
    }
}

Initialize your coordinator

To implement Coordinator in your app you should initialize it in your SceneDelegate.

func scene(
	_ scene: UIScene,
	willConnectTo session: UISceneSession,
	options connectionOptions: UIScene.ConnectionOptions
) {
	guard let windowScene = scene as? UIWindowScene else { return }

    let coordinator = Coordinator(
        container: InjectionContainerKey.defaultValue,
        appStyle: AppStyleKey.defaultValue
    )
    coordinator.showGreenScreen()
    self.coordinator = coordinator

    let window = UIWindow(windowScene: windowScene)
    window.rootViewController = coordinator.navigationController
	self.window = window
	window.makeKeyAndVisible()
}

Coordinator

In the end

To sum up, NavigationView will be great one day, but IMHO I don’t think this day is today or tomorrow. Until this day, we still can join the best from SwiftUI with UIKit and using UINavigationController with SwiftUI Views. Like the icing on the cake we can bring Coordinator to our architecture and separate layers from each other.