Refactoring UIKit Storyboards monolith to SwiftUI: Part 2

Part 2 — Laying our foundation

Ignas Pileckas
4 min readFeb 28, 2024

Continuing where we left off in Part 1, we made a plan on what we are going to tackle first in moving forward with refactoring AttaPoll’s UIKit Storyboard monolith.

The crucial pain points we defined were:

  • iOS 12 support, No SceneDelegate present.
  • AppDelegate handling UI, direct calls being made to AppDelegate within Services, no coordinator classes.
  • Storyboards, storyboards everywhere.

So in Part 2, we’ll continue where we left off and I’ll show you how I tackled these problems one by one.

AttaPoll connects you with a wide range of companies and organisations that are looking for your views and opinions. Pretty simple, complete tasks (mostly surveys) — earn money. Use this referral link to Sign Up and receive an initial starting bonus!

AppDelegate presenting UI

First things first, if we want to move the UI presentation logic out of the AppDelegate, we need to add a SceneDelegate to the project.

This ensures that the AppDelegate lifecycle is not responsible for any presentation logic nor handling the main app UIWindow. In my case, the AppDelegate had a few hundred lines of code where it presents UI directly onto the app’s UIWindow setting a different rootViewController onto it.

Supported iOS versions

The AttaPoll iOS App was supporting versions from iOS 12 and up, but if you look at the Apple Docs, UISceneDelegate needs a minimum version of iOS 13.

With iOS 17 being the latest version and looking at the number of users we have on different versions, we decided to bump the minimum supported OS to iOS 14.

Note: When you bump your app’s minimum iOS version, old users using legacy versions will still be able to use your app, but won’t get any new updates.

SceneDelegate integration

Great, now that we meet the requirements. we can create our SceneDelegate and use it to present UI. There are a ton of guides on Medium and other resources which show how to integrate UISceneDelegate into existing UIKit apps, so I won’t go through the process, but I’ll link a few articles into the references.

After successfully setting up our SceneDelegate, and moving the UI out of there, we still have a problem — there’s still business logic being handled and UI presented there.

This is not the responsibility of the SceneDelegate, it’s only purpose is to handle our app’s user interface life-cycle events, such as the scene going to background/returning to foreground and such.

AppCoordintor

That’s why I decided to create a separate class that handles the scene presentation on the UIWindowScene.

Since I really enjoy Combine, Reactive programming and Coordinators I used Zafar Ivaev Combine wrapper which is basically just a class with 20 lines of code that’s a simple boilerplate — https://github.com/zafarivaev/ReactiveCoordinator-Combine

I think it’s a very simple, lightweight solution on handling UI presentations in deeper layers and getting events on specific actions performed via the Combine publisher.

So now our SceneDelegate should look like:

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
var windowScene: UIWindowScene?
var appCoordinator: AppCoordinator?
var cancellables = Set<AnyCancellable>()

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

let startingWindow = UIWindow(windowScene: windowScene)

window = startingWindow

appCoordinator = AppCoordinator(window: startingWindow)

appCoordinator?
.start()
.sink { _ in }
.store(in: &cancellables)
}
}

Here in the method func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions)

we initialise our AppCoordinator and pass the UIWindowScene

But how does our AppCoordinator look?

final class AppCoordinator: Coordinator<Void> {
var window: UIWindow

init(window: UIWindow) {
self.window = window
}

override func start() -> AnyPublisher<Void, Never> {
coordinateToSplash()

return Empty(completeImmediately: false)
.eraseToAnyPublisher()
}

private func coordinateToSplash() {
splashCoordinator = SplashCoordinator(window: window)
guard let splashCoordinator else { return }

coordinate(to: splashCoordinator)
.sink { [weak self] result in
guard let self else { return }
switch result {
case .didGetStartupConfig(let config):
startupConfig = config
configureAppState(with: config)
}
}
.store(in: &cancellables)
}
}

Here we see a couple of things happening.

Our coordinator’s start() method is being called in SceneDelegate when launching our app and then it calls it’s internal method coordinateToSplash() which as the title suggests, shows AttaPoll’s splash screen.

There, we have some asynchronous methods being called and we wait for our Combine publisher from the SplashCoordinator to wait for those methods to finish, which returns us the didGetStartupConfig result and some startup configuration along with it.

Like in most apps, our startup config is pretty simple, we check the user’s authentication and fetch some additional info which in turn configures our AppState as seen in the method.

Storyboards, storyboards everywhere

Great, so far we have managed to clean up around 200 lines of code from the AppDelegate, moving the presentation logic to our new AppCoordinator, with its life-cycle being handled in SceneDelegate.

Part Three is coming very soon where I’ll thoroughly explain how I presented Storyboards within a custom SwiftUI TabView, explaining the biggest pain points which were:

  • Storyboards have segues that are sometimes performed on specific Deep-links that the app handles, which navigates deeper within the storyboard’s view hierarchy.
  • We want to hide our SwiftUI Tab View once we go deeper within the Storyboard’s view hierarchy. How do we get a callback from our storyboard to handle this?

--

--

Ignas Pileckas

Senior/Lead iOS Developer. Apple WWDC2020 Student Challenge winner.