🏊‍♂️

UIAlertControllerをConcurrencyに使う

2023/01/29に公開

非同期タスクを連結する際に、途中の値をユーザーに確認してから継続の判断がしたいことがあります。
その場合、一度UIAlertControllerなどを介して次のようにユーザーに選択を迫ります。
例えば、最新の利用規約を取得してユーザーが同意したらサインイン画面に遷移するワークフローは次のように書くことができます。

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()
    ...
  }
}

Concurrencyを使うと処理の流れが分かりやすくなります。
ところが、上記のようにUIAlertControllerが挟まることでTaskが分離してしまい可読性が落ちてしまっています。
次のようにUIAlertControllerをラップすることで、全てを1つのTaskの中で実行してみましょう。

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)
        }
    }
}

このラッパーを使うことで、最初の処理を次のように書き換えることができます。
選択肢をenumで渡すようにしたのでまずはそれを用意します。

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
        }
    }
}

処理は次のように書き換えられます。

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
    }
}

これで非同期処理とユーザーの操作を一連のTaskで表現することができました。
万能そうに見えますが、UIAlertControllerをcancelハンドラにかからないで閉じる方法が発明されると出れなくなるため注意が必要です。

Discussion