Search code examples
swiftasynchronousuikitseguealert

Presenting a series of alert view controllers sequentially, then performing a Present Modally segue - simultaneous presentation errors sometimes occur


In a certain app I'm developing, there are occasions where the user may be shown multiple popups (UIAlertViewControllers) in a row. Sometimes, this coincides with a Present Modally segue directly afterwards (i.e. the user presses a button, which is supposed to display all alerts in order, then perform a segue to a different screen). The user has the option of dismissing the alerts manually with the 'OK' button, but if they do nothing, each alert will automatically disappear after a set time.

It isn't absolutely essential that the user sees these popups, as they are just to notify the user that they have gained a new achievement. Users can always check their achievements by going directly to that page.

After a lot of experimentation, I got to the setup I have now. This mostly works as intended, except for the specific case where the user attempts to dismiss an alert manually right before that alert was set to disappear. This causes an error to be logged to the console that it's attempting to present an alert when one is already being presented. Other than this message -- which of course the user won't see :) --, the only issues caused by this error are some alert popups getting skipped, and the possibility that the user must press the button a second time to trigger the Present Modally segue to the next screen.

Although the issue is minor, I'm still looking to eliminate it completely if possible. I'm open to completely redoing the timing mechanism if the current implementation is flawed and someone proposes an alternative. (In other words, I realize this may be an instance of the "XY problem" that's often discussed on Meta!)

Below is an MCVE which reproduces the timing issue. The desired behavior is to see the 4 alerts pop up in order, followed by the segue from the first view controller to the second. There are two View Controller scenes in Main.storyboard, with segues connecting them in each direction. Each View Controller has a UIButton which is connected to an IBAction to perform the segue to the other VC.

Note that allowing each alert to time out causes no errors. Similarly, dismissing each alert manually as soon as it appears (or shortly afterwards) also causes no errors. The only situation I've encountered where an error may occur is when an alert appears and you attempt to dismiss it very close to when it should auto-dismiss (3 seconds after it appears).

FIRST VIEW CONTROLLER

import UIKit

class ViewController: UIViewController {
    // A failsafe in case the event queue timing gets messed up.  This kind of error could otherwise cause an infinite cycle of alert view controllers being shown whenever the button is pressed, preventing the main segue from ever being performed.  Having this boolean ensures that alert VCs can only be shown the first time the button is pressed, and that regardless of event queue 'success' or 'failure', a subsequent button press will always trigger the main segue (which is what is wanted).
    var showedAlerts = false
    var alertMessages = ["1st Item", "2nd Item", "3rd Item", "4th Item"]

    override func viewDidLoad() {
        super.viewDidLoad()
    }
    
    @IBAction func goForward(_ sender: UIButton) {
        if !showedAlerts {
            for i in 0..<alertMessages.count {
                // This function is defined in Constants.swift
                K.alerts.showAlertPopup(alertMessages[i], counter: K.alerts.alertCounter, VC: self)
            }
            showedAlerts = true
        }
        // Refer to Constants.swift for documentation of these variables
        if K.alerts.canPresentNextSegue {
            self.performSegue(withIdentifier: K.segues.toSecond, sender: self)
        } else {
            K.alerts.suspendedSegueParameters = (identifier: K.segues.toSecond, VC: self)
        }
    }
}

SECOND VIEW CONTROLLER

import UIKit

class SecondViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
    }
    
    @IBAction func goBack(_ sender: UIButton) {
        self.performSegue(withIdentifier: K.segues.toFirst, sender: self)
    }
}

CONSTANTS FILE

import UIKit

struct K {
    struct segues {
        static let toSecond = "toSecondVC"
        static let toFirst = "toFirstVC"
    }
    struct alerts {
        // Avoids segue conflicts by showing alert VC popups sequentially, and delaying any 'present modally' segue until all popups have been shown
        static var canPresentNextSegue = true {
            didSet {
                if canPresentNextSegue == true {
                    if !suspendedAlertParameters.isEmpty {
                        // Take the first element out of the array each time, not the last, otherwise the order of all popups after the first will be reversed (i.e. will show popups in order of 1st, 4th, 3rd, 2nd)
                        let p = suspendedAlertParameters.removeFirst()
                        showAlertPopup(p.alertItem, counter: alertCounter, VC: p.VC)
                    }
                    // Don't perform the main segue until all alert popups have been shown!  This should be true when the suspendedAlertParameters array is empty.
                    else if let p = suspendedSegueParameters {
                        p.VC.performSegue(withIdentifier: p.identifier, sender: p.VC)
                        suspendedSegueParameters = nil
                    }
                }
            }
        }
        // The purpose of this counter is to ensure that each Alert View Controller has an associated unique ID which can be used to look it up in the alertDismissals dictionary.
        static var alertCounter = 0
        // Keeps track of which alert VCs have been dismissed manually by the user using the 'OK' button, to avoid the DispatchQueue block in 'showAlertPopup' from setting the status of canPresentNextSegue to 'true' erroneously when the already-dismissed alert 'times out' and attempts to dismiss itself again
        static var alertDismissals: [Int: Bool] = [:]
        // Tuple representations of any alert view controllers which were not able to be immediately presented due to an earlier alert VC still being active.  This allows them to be retrieved and presented once there is an opening.
        static var suspendedAlertParameters: [(alertItem: String, counter: Int, VC: UIViewController)] = []
        // Analogous to the preceding variable, but for the main 'Present Modally' segue
        static var suspendedSegueParameters: (identifier: String, VC: UIViewController)? = nil
        
        static func showAlertPopup(_ alertItem: String, counter: Int, VC: UIViewController) {
            alertDismissals[counter] = false
            alertCounter += 1  // Automatially increment this, so that the next alert has a different ID
            
            // Present the alert if there isn't one already being presented
            if canPresentNextSegue {
                let alert = UIAlertController(title: "Test Alert", message: alertItem, preferredStyle: .alert)
                let OKButton = UIAlertAction(title: "OK", style: .cancel) { (action) in
                    alertDismissals[counter] = true
                    canPresentNextSegue = true
                    return
                }
                alert.addAction(OKButton)
                VC.present(alert, animated: true, completion: nil)
                
                // Automatically dismiss alert after 3 seconds
                DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
                    if alertDismissals.keys.contains(counter) && !alertDismissals[counter]! {
                        alert.dismiss(animated: true)
                        canPresentNextSegue = true
                    }
                }
                // Because this current alert is now being shown, nothing else can be presented until it is dismissed, resetting this boolean to 'true' (either with the OK button or the DispatchQueue block)
                canPresentNextSegue = false
            }
            // If there is another alert being presented, store this one in tuple representation for later retrieval
            else {
                suspendedAlertParameters.append((alertItem: alertItem, counter: counter, VC: VC))
            }
        }
    }
}

Solution

  • Since you are not familiar with RxSwift I present below the entirety of the solution. This solution doesn't use segues. The CLE library takes over all view controller routing for you. It does this with generic coordinators that it creates and destroys for you so you never have to worry about them.

    1. Create a new project.
    2. Import the RxSwift, RxCocoa and Cause_Logic_Effect libraries.
    3. Remove all the swift code in it and the storyboard. Remove the manifest from the info.plist and remove "main" from the "Main interface" entry in the target.
    4. Add a new .swift file to the project and paste all the code below into it.
    import Cause_Logic_Effect
    import RxCocoa
    import RxSwift
    import UIKit
    
    // the app delegate for the app.
    @main
    final class AppDelegate: UIResponder, UIApplicationDelegate {
    
        var window: UIWindow?
    
        func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    
            window = {
                let result = UIWindow(frame: UIScreen.main.bounds)
                result.rootViewController = ViewController().configure { $0.connect() }
                result.makeKeyAndVisible()
                return result
            }()
            return true
        }
    }
    
    // the main view controller, notice that the only thing in the VC itself is setting up the views. If you use a
    // storyboard or xib file, you can delete the viewDidLoad() completely.
    final class ViewController: UIViewController {
        @IBOutlet var goForwardButton: UIButton!
        let disposeBag = DisposeBag()
    
        override func viewDidLoad() {
            super.viewDidLoad()
            // create the view. This could be set up in a xib or storyboard instead.
            view.backgroundColor = .white
            let button = UIButton(frame: CGRect(x: 100, y: 100, width: 50, height: 50)).setup {
                $0.backgroundColor = .green
            }
            view.addSubview(button)
            goForwardButton = button
        }
    }
    
    // The second view controller, same as the first but the button name and color is different.
    final class SecondViewController: UIViewController {
        @IBOutlet var goBackButton: UIButton!
        override func viewDidLoad() {
            super.viewDidLoad()
            // create the view. This could be set up in a xib or storyboard instead.
            view.backgroundColor = .white
            let button = UIButton(frame: CGRect(x: 100, y: 100, width: 50, height: 50)).setup {
                $0.backgroundColor = .red
            }
            view.addSubview(button)
            goBackButton = button
        }
    }
    
    // here's where the magic happens.
    extension ViewController {
        func connect() {
            let alertMessages = ["1st Item", "2nd Item", "3rd Item", "4th Item"]
            goForwardButton.rx.tap
                .flatMap {
                    displayWarnings(messages: alertMessages, interval: .seconds(3))
                }
                .subscribe(onNext: presentScene(animated: true) {
                    SecondViewController().scene { $0.connect() }
                })
                .disposed(by: disposeBag)
    
            /*
             When the goForwardButton is tapped, the closure in the flatMap will present the alerts, then the closure in the
             subscribe will present the second view controller.
             */
        }
    }
    
    func displayWarnings(messages: [String], interval: RxTimeInterval) -> Observable<Void> {
        Observable.from(messages)
            .concatMap(presentScene(animated: true) { message in
                UIAlertController(title: nil, message: message, preferredStyle: .alert)
                    .scene { $0.dismissAfter(interval: interval) }
            })
            .takeLast(1)
        /*
         This displays the alerts in succession, one for each message in the array and sets up the dismissal.
         When the last alert has been dismissed, it will emit a next event and complete. Note that this function is reusable
         for any number of alerts with any duration in any view controller.
         */
    }
    
    extension UIAlertController {
        func dismissAfter(interval: RxTimeInterval) -> Observable<Void> {
            let action = PublishSubject<Void>()
            addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in action.onSuccess(()) }))
            return Observable.amb([action, .just(()).delay(interval, scheduler: MainScheduler.instance)])
            /*
             This will close the alert after `interval` time or when the user taps the OK button.
             */
        }
    }
    
    extension SecondViewController {
        func connect() -> Observable<Void> {
            return goBackButton.rx.tap.take(1)
            /*
             When the user taps the goBackButton, this will notify the CLE library that it's complete and the library will dismiss it.
             */
        }
    }
    

    You almost could do this with standard callback closures, except implementing the concatMap with callback closures would be a huge PITA. If you look inside the CLE library, you will see that it sets up a serial queue to present/dismiss view controllers and waits for their completion before allowing the next one to present dismiss. Also, it finds the top view controller itself so you don't ever have to worry about presenting from a VC that is already presenting something.