iosswiftuialertcontrollerswift-continuations

I can't quite work out how to extend a view controller with an async alert controller


Note that you very likely would NOT do this in a production app as continuations easily leak. (Although it is incredibly useful during development.)

I'm trying to write an asynchronous alert that returns after the user hits OK.

await simple(alert: "Let's proceed..")
await comms.sendInfo()
await simple(alert: "Successful! Next step?")
await some.process()

The code would be something like,

extension UIViewController {
    
    ///UNSAFE async trivial alert.
    func simple(alert: String) async {
        let alert = UIAlertController(title: "", message: alert, preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "Understood", style: .default) { _ in
            await withCheckedContinuation { inct in
                alert.dismiss(animated: false) {
                    inct.resume()
                }
            }
        })
        await withCheckedContinuation { ct in
            present(alert, animated: false) {
                ct.resume()
            }

That's completely wrong though. (Invalid conversion from 'async' function.)

This answer has some info, but I just can't figure the syntax to do it all inline.

How to?


Solution

  • Since you want the continuation to resume after the user presses the alert action, you should resume the continuation in there.

    func simple(alert: String) async {
        await withCheckedContinuation { ct in
            let alert = UIAlertController(title: "", message: alert, preferredStyle: .alert)
            alert.addAction(UIAlertAction(title: "Understood", style: .default) {  _ in
                ct.resume()
            })
            present(alert, animated: false)
        }
    }
    

    Note that you don't need to dismiss the alert in the alert action. Doing that does not have any effect. If you want to dismiss the alert without animation, you need to override dismiss in the presenting view controller (see here).

    Also, this assumes that the alert will only be dismissed by pressing the alert action and not by any other means. If the alert is dismissed by other means, or failed to present at all, then you will end up leaking the continuation, and the task will never complete!

    For example, if you call simple(alert:) while an existing alert has not been dismissed, the alert will fail to be presented, and you get something like this in the logs:

    Attempt to present UIAlertController on MyViewController (from MyViewController) which is already presenting UIAlertController.

    This means you will never end up calling ct.resume! The Swift runtime detect this and logs

    SWIFT TASK CONTINUATION MISUSE: simple(alert:) leaked its continuation!

    To handle this particular situation, you could add a check,

    func simple(alert: String) async throws {
        if presentingViewController != nil {
            throw CancellationError()
        }
        await withCheckedContinuation { ct in ... }
    }
    

    But there might be many other things that could happen to cause the user to be unable to press the alert action, thereby leaking the continuation. Overall, I would not recommend presenting an alert using a continuation this way.