27

If you drag the edge of a UIViewController to begin an interactive pop transition within a UINavigationController, the UIViewController underneath the current has viewWillAppear: called, followed by the UINavigationControllerDelegate method navigationController:willShowViewController:animated:.

If you cancel the transition (i.e. the dragged controller is placed back where it was and not popped), viewWillAppear: and viewDidAppear: are called on the top view controller as expected, but the delegate methods navigationController:willShowViewController:animated: and navigationController:didShowViewController:animated: aren't. It seems like at least one or both of these should be called considering the UIViewController view lifecycle methods are called. I am wondering whether this is deliberate or a bug in UINavigationController.

What I really need is to be able to see when an interactive pop is cancelled, either within my UINavigationController subclass, or its UINavigationControllerDelegate. Is there an obvious way to do this?

edit

I'm still looking for a solution to this but would like to mention that I have reported this issue as a bug with Apple. Looking at the documentation, there is no reason these delegate methods should not get called, especially considering the equivalent view lifecycle methods DO get called.

edit2

My radar ticket (16823313) was closed today (May 21st, 2015) and marked as intended. :(

Engineering has determined that this issue behaves as intended based on the following information:

This is actually the correct behavior. The navigation transition that's happening from B -> A, if you cancel it mid-transition, you won't get the didShowViewController: method. A cancellation of this transition shouldn't be considered a transition from A -> B because you never actually reached A.

view[Will/Did]Appear should still be called as expected too.

Quite a bummer this is the case as it is counterintuitive but the workaround in my answer below should work fine for the foreseeable future, at least for my use-case.

malhal
  • 26,330
  • 7
  • 115
  • 133
Dima
  • 23,484
  • 6
  • 56
  • 83
  • do you have a bug report number that I can dupe? – Tim Arnold Apr 29 '15 at 15:44
  • Hi Tim. My bug report number is `16823313`. They tried to mark it as fixed with the first iOS 8 beta last June and I opened it back up after confirming it was still broken. No activity since then. – Dima Apr 29 '15 at 16:12
  • Why does the 1st paragraph say navigationController:willShowViewController: is called but the 3rd paragraph says it isn't called? – malhal Oct 05 '19 at 16:25
  • @malhal the methods are called when you start an interactive pop, but not when you cancel it. – Dima Oct 06 '19 at 04:47
  • Thanks I now understand, hope you don't mind I edited the question to combine the 2nd and 3rd paragraphs since they are talking about the same case. I'm still figuring out my answer to the question but just to let you know I do get a call to willShowViewController after the gesture is cancelled (Xcode 11.1 iPhone 8 Plus simulator portrait). – malhal Oct 07 '19 at 10:42

3 Answers3

36

For anyone interested, I have found 2 ways to work around this at the UINavigationControllerDelegate level.

  1. Use KVO to observe the state property of the interactivePopGestureRecognizer. Unfortunately, canceling the transition does not change the state to UIGestureRecognizerStateFailed but instead just UIGestureRecognizerStateEnded, so you would need to write a bit of additional code to keep track of what happened if you needed to discern between a cancelled or completed pop.

  2. After testing it, this is probably the better solution: Use the navigationController:willShowViewController:animated: method to add a notification block to the transition coordinator. It looks something like this:

    - (void)navigationController:(UINavigationController *)navigationController willShowViewController:(UIViewController *)viewController animated:(BOOL)animated
    {
        [[self transitionCoordinator] notifyWhenInteractionEndsUsingBlock:^(id<UIViewControllerTransitionCoordinatorContext> context)
        {
            if([context isCancelled])
            {
                UIViewController *fromViewController = [context viewControllerForKey:UITransitionContextFromViewControllerKey];
                [self navigationController:navigationController willShowViewController:fromViewController animated:animated];
    
                if([self respondsToSelector:@selector(navigationController:didShowViewController:animated:)])
                {
                    NSTimeInterval animationCompletion = [context transitionDuration] * (double)[context percentComplete];
    
                    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)((uint64_t)animationCompletion * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
                        [self navigationController:navigationController didShowViewController:fromViewController animated:animated];
                    });
                }
    
    
            }
        }];
    }
    

I was hesitant to use this solution at first because the documentation was unclear about whether or not you could set more than one of these (since in that case, if an unknowing view controller also set its own notification block, it could potentially either replace this one or get replaced by this one). After testing it though, it appears that it is not a 1:1 relationship and you can add multiple notification blocks safely.

edit

I edited the code above to delay the navigationController:didShowViewController:animated: call to only be called when the animation is supposed to be completed to more closely match the expected default behavior.

Krin-San
  • 316
  • 1
  • 10
Dima
  • 23,484
  • 6
  • 56
  • 83
  • Great solution! I was facing the same problem. But what about viewControllers/topViewController? At the moment you call these methods the view controller is not added to navigation controller stack, thus topViewController is holding old value. – deej May 30 '14 at 19:43
  • Just noticed it fires when the gesture ends but view controller continues moving – deej May 30 '14 at 19:59
  • Also you can get the state without KVO using the solution from http://stackoverflow.com/questions/21298051 – deej May 30 '14 at 20:19
  • If you look at my implementation, I do not access any properties of the `UINavigationController`. I use the `UITransitionContextFromViewControllerKey` key of the transition context, which holds the value of the view controller being popped. Does this clear it up? – Dima May 30 '14 at 20:23
  • Also thanks for posting that alternative of using target/actions instead of kVO. That may be more convenient but unfortunately does not solve the issue of seeing if it is cancelled. The transitionCoordinator solution is still better and simpler in my opinion. – Dima May 30 '14 at 20:24
  • Good catch regarding firing the delegate methods too early. At a glance I don't see a way to fix this 100% but we can delay the `didShowViewController:` method by using the `transitionDuration` and `percentComplete` methods of the animation context. multiplying those 2 values should give you the time until the cancellation animation is complete. – Dima May 30 '14 at 21:02
  • The order of the the 4 method calls is still not quite right but good enough for my purposes. Perhaps you have an idea as to how to improve the timing to guarantee `viewWillAppear > willShowViewController > viewDidAppear > didShowViewController`? I tried messing around with delays but the results were not consistent. – Dima May 30 '14 at 21:04
  • 1
    You will never get consistent result with delays. However your second solution is enough for me as well. – deej May 30 '14 at 21:27
  • @deej So it turns out you can't use `performSelector:...etc` to call a method that requires multiple parameters so I switched to using `dispatch_after` for my delay and calling the method normally. Updating code now. – Dima Jun 04 '14 at 08:47
  • Just so we are clear, multiple of these blocks CAN be set per Apple documenation "You can call this method multiple times to register multiple blocks. All of the registered blocks are executed when the transition state changes." https://developer.apple.com/library/ios/documentation/UIKit/Reference/UIViewControllerTransitionCoordinator_Protocol/#//apple_ref/occ/intfm/UIViewControllerTransitionCoordinator/notifyWhenInteractionEndsUsingBlock: – Josh Bernfeld Aug 17 '15 at 00:00
  • 1
    If you really want to know when the transition ended, you can put your `didShow` code in the completion block of `[UIViewControllerTransitionCoordinator animateAlongsideTransition:completion]` instead of `[UIViewControllerTransitionCoordinator notifyWhenInteractionEndsUsingBlock]`. Of course this is for iOS 8 and above. – David Robles Aug 18 '15 at 05:31
7

Swift 3:

func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
    transitionCoordinator?.notifyWhenInteractionEnds { context in
        guard context.isCancelled, let fromViewController = context.viewController(forKey: UITransitionContextViewControllerKey.from) else { return }
        self.navigationController(self, willShow: fromViewController, animated: animated)
        let animationCompletion: TimeInterval = context.transitionDuration * Double(context.percentComplete)
        DispatchQueue.main.asyncAfter(deadline: .now() + animationCompletion) {
            self.navigationController(self, didShow: fromViewController, animated: animated)
        }
    }
}
Kurt J
  • 2,558
  • 24
  • 17
  • Thanks for addition! – Dima Oct 13 '16 at 20:00
  • Dude, this is awesome. Thank you so much for this answer. You should edit it and include some comments on what this thing does so people know. To anyone reading, what this does is basically, if the navigation is cancelled, it reverses the controllers and call the delegates again. So you can include this at the top of the willShow function and all your logic below. it executes the logic just fine and if cancelled, it executes the logic in reverse so you don't loose your states/animations – gmogames Jul 17 '18 at 02:34
  • 1
    `notifyWhenInteractionEnds` has been deprecated in iOS 10 – strangetimes May 13 '20 at 12:45
  • @strangetimes just change it to `notifyWhenInteractionChanges` and it works exactly the same – Adam Jul 11 '20 at 10:01
1

I translated @Dima's answer to Swift for my project:

func navigationController(navigationController: UINavigationController, willShowViewController viewController: UIViewController, animated: Bool) {

    transitionCoordinator()?.notifyWhenInteractionEndsUsingBlock { context in
        guard context.isCancelled(), let fromViewController = context.viewControllerForKey(UITransitionContextFromViewControllerKey) else { return }

        self.navigationController(self, willShowViewController: fromViewController, animated: animated)

        let animationCompletion: NSTimeInterval = context.transitionDuration() * Double(context.percentComplete())

        let delayTime = dispatch_time(DISPATCH_TIME_NOW, Int64(1 * Double(NSEC_PER_SEC)))
        dispatch_after(delayTime, dispatch_get_main_queue()) {
            self.navigationController(self, didShowViewController: fromViewController, animated: animated)
        }            
    }

    /* Your normal behavior goes here */

}

Note that I don't check for the existence of an implementation of navigationController(_:didShowViewController:animated:), although I believe this is checked at compile-time in Swift, and that you'll get a compiler error if you attempt to call this when it's unimplemented.

Community
  • 1
  • 1
Tim Arnold
  • 8,359
  • 8
  • 44
  • 67
  • 1
    Great job! I'd like to add though, that this compile time error will occur in Objective-C as well. The reason I put this check in in my original answer is so that you wouldn't have to modify this patch code based on whether or not you implement the `...didShow...` method. Basically, it's a convenience as the patch will work whether you have it or not. – Dima Apr 13 '16 at 21:54