In January 2024, I wrote about a technique for managing serial modal views on iOS by subclassing Operation and using a serial OperationQueue. As it turns out, that technique had its fair share of problems.

First, OperationQueue and Operation are sharp tools: using them is hard. It’s easy to cut yourself. Even though I really tried, I never quite got rid of all the problems in my OperationQueue based approach, so it kept causing odd problems and hard-to-debug data races. I’m sure it’s possible to use this tools properly, I just couldn’t get to that point.

Second, OperationQueue and Operation don’t play nice with Swift Concurrency. For example, there is no compile-time isolation checking, so it’s easy to accidentally update a property from the wrong context. Operation is declared as @unchecked Sendable, which means any subclasses also are @unchecked Sendable, so already at that point you’ve opted out of compile-time concurrency checks.

Since introducing this technique in my current project, I’ve had a long tail of rarely occurring crashes. Crashes I simply couldn’t fix. And eventually, I had enough. There had to be a better way. And there is ✨.

The better way™

Instead of using Operation to model our modal views, we’re going to use Task. And instead of using an OperationQueue for ordering and serial execution, we’ll simply hold a list of async functions that are waiting to be run. Sounds easy, right? That’s because it is. Let’s dive in!

LaunchStep

Each modal operation will conform to the protocol LaunchStep:

@MainActor
protocol LaunchStep {
    var name: String { get }
    func run() async
}

extension LaunchStep {
    var name: String { String(describing: Self.self) }
}

It’s a simple protocol, containing a name and an async run function. Conveniently, the name property can be automatically provided by defaulting to the type name of the implementation, so all we have to do is provide a run function!

Here’s how a launch step fetching an app upgrade nudge might look like:

final class AppUpgradeNudgeLaunchStep: LaunchStep {
    private let appUpgradeNudgeService: AppUpgradeNudgeService
    private let presentNudge: @MainActor (UIViewController) -> Void

    init(
        appUpgradeNudgeService: AppUpgradeNudgeService,
        presentNudge: @MainActor @escaping (UIViewController) -> Void
    ) {
        self.appUpgradeNudgeService = appUpgradeNudgeService
        self.presentNudge = presentNudge
    }

    func run() async {
        guard let nudge = try? await appUpgradeNudgeService.fetchAppUpgradeNudge() else {
            return
        }

        let nudgeViewController = NudgeViewController(
            appUpgradeNudgeService: appUpgradeNudgeService,
            completion: nil
        )

        guard !Task.isCancelled else {
            return
        }

        presentNudge(nudgeViewController)
    }
}

It’s quite simple. It uses DI to inject dependencies for fetching and presenting the nudge, and performs all of its fetching and presenting logic in the async run method (a requirement of the LaunchStep protocol).

Launch steps can contain much more complexity though, here’s an example of a launch step presenting a passcode challenge to the user:

final class PasscodeLaunchStep: LaunchStep {
    private let userPreferences: UserPreferences
    private let present: @MainActor (UIViewController) -> Void

    init(
        userPreferences: UserPreferences,
        present: @MainActor @escaping (UIViewController) -> Void
    ) {
        self.userPreferences = userPreferences
        self.present = present
    }

    func run() async {
        guard userPreferences.value(
            forKey: .isPasscodeEnabled
        ) == true else { return }

        var presentedViewController: PasscodeViewController?
        var delegate: PasscodeViewControllerDelegate?
        let userPreferences = self.userPreferences
        let present = self.present

        await withTaskCancellationHandler {
            await withCheckedContinuation { continuation in
                let viewController = PasscodeViewController.make(
                    userPreferences: userPreferences,
                    context: .unlock
                )

                presentedViewController = viewController
                delegate = Delegate(
                    onUnlocked: {
                        continuation.resume()
                    }
                )
                viewController.delegate = delegate

                guard !Task.isCancelled else {
                    continuation.resume()
                    return
                }

                present(viewController)
            }
        }
        onCancel: {
            Task { @MainActor in
                presentedViewController?.dismiss(animated: false)
            }
        }
    }
}

fileprivate final class Delegate: PasscodeViewControllerDelegate {
    var onUnlocked: () -> Void

    init(onUnlocked: @escaping () -> Void) {
        self.onUnlocked = onUnlocked
    }

    func passcodeUnlocked(
        _ passcodeViewController: PasscodeViewController
    ) {
        onUnlocked()
    }
}

This example is a bit more complex, with lots of things happening. It deals with task cancellation, it wraps a delegate into a continuation, and more.

Okay, so now that we’ve had a look at two launch steps, how do we orchestrate them? It’s time to introduce LaunchSequencer.

LaunchSequencer

@MainActor
final class LaunchSequencer {
    private var currentTask: Task<Void, Never>? = nil
    private var pendingSteps: [any LaunchStep] = []

    func enqueue(_ step: LaunchStep) {
        pendingSteps.append(step)
        processQueueIfNeeded()
    }

    func cancelAll() {
        currentTask?.cancel()
        currentTask = nil
        pendingSteps.removeAll()
    }

    private func processQueueIfNeeded() {
        guard currentTask == nil else { return }

        guard !pendingSteps.isEmpty else { return }
        let step = pendingSteps.removeFirst()

        currentTask = Task {
            await step.run()
            stepFinished()
        }
    }

    private func stepFinished() {
        currentTask = nil
        processQueueIfNeeded()
    }
}

LaunchSequencer is responsible for managing the ordering, serial execution, and cancellation of launch steps. It holds state for the currently executing task (derived from a launch step’s run method), and a list of pending launch steps.

For adding a new launch step, it provides the enqueue method, which when called adds the passed-in launch step to the list of pending steps, and starts processing the queue if necessary.

Upon completion of each launch step, it will clear the current task, and start processing the queue again if needed.

The cancelAll method will cancel and nil out the ongoing task (if any), and clear the list of pending launch steps.

Usage

So, how should we use this? Well, here’s how it’s used in the app I currently work on:

private func addLaunchSteps() {
    launchSequencer.enqueue(
        AppUpgradeNudgeLaunchStep(
            appUpgradeNudgeService: ...,
            presentNudge: { ... }
        )
    )

    launchSequencer.enqueue(PasscodeLaunchStep(userPreferences: ...))
    launchSequencer.enqueue(RemoveLaunchScreenLaunchStep())

    if let pendingDeeplink {
        launchSequencer.enqueue(DeepLinkLaunchStep(deeplink: pendingDeeplink))
    }

    if appWasUpgraded {
        launchSequencer.enqueue(WhatsNewLaunchStep(whatsNewService: ...))
    }
}

This function is called every time the app is launched or brought into the foreground. It makes sure that these operations run every time, in the same order, when needed.

Wrapping up

This is a more modern way of dealing with serial modal views in Swift, leveraging compile-time concurrency checks, and is overall simply less code and less error-prone than subclassing Operation and managing its different states.

The solution described in this blog post is already live in my app, and is already proving itself by in several ways:

  • Isolation enforced at compile time

  • No manual state transitions

  • Cancellation is explicit and structured

  • No reliance on KVO-backed Operation state

If you happen to spot something that can be improved (either in the code I posted, or in this post), please give me a shout out on Mastodon.

A note on SwiftUI

I use this an app that mainly uses UIKit at this level, so I can’t speak for how well this pattern works for SwiftUI apps.