iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🏊‍♂️

Using UIAlertController with Swift Concurrency

に公開

When chaining asynchronous tasks, you may want to check a value with the user before deciding whether to continue.
In such cases, you prompt the user for a choice via a UIAlertController or similar as follows.
For example, a workflow that fetches the latest terms of use and transitions to the sign-in screen if the user agrees can be written like this:

Workflow.swift
func termsOfUseWorkflow() async throws {
  let termsOfUse = try await latestTermsOfUse()
  presentAlert(termsOfUse)
}

func presentAlert(termsOfUse) {
  let alert = UIAlertController(termsOfUse)
  alert.addAction("Accept") { 
    acceptedTermsOfUse()
  }
  alert.addAction("Cancel") { 
  }
  present(alert)
}

func acceptedTermsOfUse() {
  Task {
    await signin()
    ...
  }
}

Using Concurrency makes the flow of processing easier to understand.
However, as shown above, when a UIAlertController is involved, the Task becomes separated, which reduces readability.
Let's try to execute everything within a single Task by wrapping UIAlertController as follows.

UIAlertController+Concurrency.swift
protocol UIAlertActionStylePresentable {
    var style: UIAlertAction.Style { get }
}

extension UIAlertController {
    @MainActor
    static func choice<T: RawRepresentable>(
        title: String?,
        message: String?,
        preferredStyle: UIAlertController.Style = .alert,
        animated: Bool = true,
        presentation: UIViewController
    ) async -> T where T: CaseIterable, T: CustomStringConvertible, T: UIAlertActionStylePresentable {
        await withCheckedContinuation { continuation in
            let alert = UIAlertController(title: title, message: message, preferredStyle: preferredStyle)
            for `case` in T.allCases {
                let action = UIAlertAction(
                    title: `case`.description,
                    style: `case`.style,
                    handler: { _ in
                        continuation.resume(returning: `case`)
                    }
                )
                alert.addAction(action)
            }
            presentation.present(alert, animated: animated)
        }
    }
}

By using this wrapper, you can rewrite the initial process as follows.
Since the choices are passed as an enum, let's prepare that first.

Workflow.swift

enum Choice: String, CaseIterable, CustomStringConvertible, UIAlertActionStylePresentable {
    case accept
    case cancel
    
    var description: String {
        switch self {
        case .accept:
            return "Accept"
        case .cancel:
            return "Cancel"
        }
    }
    
    var style: UIAlertAction.Style {
        switch self {
        case .accept:
            return .default
        case .cancel:
            return .cancel
        }
    }
}

The process can be rewritten as follows:

Workflow.swift
func termsOfUseWorkflow() async throws {
    let termsOfUse = try await latestTermsOfUse()
    let choice: Choice = await UIAlertController.choice(
        termsOfUse: termsOfUse,
        presentation: self
    )
    switch choice {
    case .accept:
        await signin()
	...
    case .cancel:
        break
    }
}

With this, asynchronous processing and user operations can now be represented as a single sequence of Tasks.
While it seems versatile, caution is needed because if a way to close the UIAlertController without triggering the cancel handler were to be found, it would become impossible to exit.

Discussion