0
votes

My code is performing a lengthy, async operation (HTTP). Before starting it, I present a custom modal view controller with a spinning activity indicator. On completion, I dismiss it and present an alert controller to inform the user of task completion.

My code looks like this:

// This is a custom modal view controller. It mimics the modal 
// presentation style of UIAlertController, but contains a label 
// and a spinner:
let progress = ActivityIndicatorAlertController(withTitle:"Syncing...")

self.presentViewController(progress, animated:true, completion:{

    WebService.sharedService.syncData(data, completion: {error in

        self.dismissViewControllerAnimated(false, completion: {

            // <BREAKPOINT>

            if error == nil {
                // Update UI:
                self.reloadTable()

                // This method presents a vanilla UIAlertController 
                // with the specified title and an OK 
                // button to dismiss:
                self.presentModalInformation(title:"Sync Complete", message:"")

Immediately after the last line above executes, I get this warning on the console:

Warning: Attempt to present <UIAlertController: 0x7fae3a57ad60> on <AppName.MyPresentingViewController: 0x7fae3b02a800> which is already presenting (null)

Despite the dismiss completion handler being executed (location marked <BREAKPOINT> in the code above), the first modal view controller (progress indicator) stays onscreen, and (of course) the "completion" view controller is never displayed.

To the eyes of the user, the sync operation "freezes" and the app becomes unresponsive (hung on modal presentation).

Some Observations:

  1. For completeness, here is the method that presents the second modal view controller:

    func presentModalInformation(title title:String, message:String) {
        let alertController = UIAlertController(
            title: title,
            message: message,
            preferredStyle: .Alert)
    
        let okAction = UIAlertAction(title: "OK", 
            style: UIAlertActionStyle.Default, 
            handler: nil)
    
        alertController.addAction(okAction)
    
        self.presentViewController(alertController, animated: true, completion: nil)
    }
    
  2. This issue started happening at some moment I can not pin-point, during modifications to the tasks performed via HTTP. I didn't notice the exact moment because I am on a slow connection and testing different data sets every time.

  3. UIKit complains that there already is a view controller presented modally. Indeed, if I stop execution at the location marked <BREAKPOINT> in the code above, and inspect variables via the console, I get:

       (lldb) print self.presentedViewController?.classForCoder
       (AnyClass?) $R1 = AppName.ActivityIndicatorAlertController
    

...despite all of this occurring inside the dismiss completion handler.

  1. I tried to get away with a dispatch_after() of one second, to delay the presentation of the second modal view controller (perhaps to give the first one "time" to dismiss properly?) but it doesn't work. The first one just doesn't get dismissed!

  2. <BREAKPOINT> in the code above is executed on the main thread ("com.apple.main-thread (serial)").

  3. Inserting a second call to dismiss..., as the first thing inside the completion handler, seems to fix the issue:

    self.presentViewController(progress, animated:true, completion:{
    
        WebService.sharedService.syncData(data, completion: {error in
    
            self.dismissViewControllerAnimated(false, completion: {
    
                // <BREAKPOINT>
    
                self.dismissViewControllerAnimated(false, completion:nil)
                // ^^ THIS "FIXES" IT ^^
    

...What could be going on?


Addendum: The source code for ActivityIndicatorAlertController is in this repository.

1
I think because you are presenting the modal view on one already presenting view, try to present that alertController in a new window, or pass along the parentViewController to the closure to use to present it - Tj3n
That is the whole point, it shouldn't be "one already presenting" anymore, since everything is executed inside the completion block of the dismiss. The present/dismiss calls should already be balanced. - Nicolas Miari
Dispatch your UI updates on the main queue - Paulw11
@Paulw11 Everything already happens on the main queue. My HTTP code's callbacks make sure of that (and I also checked on the debug navigator). - Nicolas Miari
Don't use the completion blocks of presentViewController and dismissViewController. Just put your code straight after the present/dismiss and see how it goes. - Paulw11

1 Answers

0
votes

After looking here and there, I found the cause (although I still don't understand the issue completely).

I decided to inspect the value of self.presentedViewController?.classForCoder at more locations. This is what I got:

self.presentViewController(progress, animated:true, completion:{

    // (lldb) print self.presentedViewController?.classForCoder
    // (AnyClass?) $R0 = UISearchController

    WebService.sharedService.syncData(data, completion: {error in

        // (lldb) print self.presentedViewController?.classForCoder
        // (AnyClass?) $R0 = UISearchController

        self.dismissViewControllerAnimated(false, completion: { 

            // (lldb) print self.presentedViewController?.classForCoder
            // (AnyClass?) $R1 = AppName.ActivityIndicatorAlertController

So, before calling dismiss..., the presented controller is a UISearchController, and after dismissing it, the instance of ActivityIndicatorAlertController is still left! (no wonder calling dismiss.., once more gives the expected result!).

Discussion

The search controller's identity tipped me, and I soon realized that this issue happens only for some data sets: I trigger the "sync" action of my app from a table cell button, but in order to display some rows, I have to first tap the scope button of the search bar above the table view. When I sync from one of the rows that are visible fro the start (no table view search), the issue does not happen.

I guess this somehow inserts an "additional layer of modal view controllers", so I need to dismiss once to get rid of the search controller, and once more to dismiss my custom progress controller.

However, I don't understand how. I was under the assumption that each view controller can present one modal view controller at the time (that's the whole reason of the warning/issue, after all!)

The search controller seems to occupy a "different level" of modal presentation: When active, it is reported as self.presentedViewController and consumes one call to dismiss.... But this does not prevent you from presenting another view controller modally (in my case, ActivityIndicatorAlertController), which gets somehow "piled up" on top of the search controller, not reported as presentedViewController, and requiring an additional call to dismiss..., before you can present another one.

Does this make sense?


So, I fixed by adding this code before the whole thing happens:

if let self.presentedViewController != nil {
    dismissViewControllerAnimated(false, completion: nil)
    // gets rid of occasional UISearchController
}

(...although I guess that deactivating the search controller should be more correct)


EDIT:

Indeed, calling:

self.resultSearchController.active = false

...before presenting the activity indicator alert controller works too (and is more correct).