128

I'm about creating an app using a UINavigationController to present the next view controllers. With iOS5 there´s a new method to presenting UIViewControllers:

presentViewController:animated:completion:

Now I ask me why isn´t there a completion handler for UINavigationController? There are just

pushViewController:animated:

Is it possible to create my own completion handler like the new presentViewController:animated:completion: ?

Eric Aya
  • 69,473
  • 35
  • 181
  • 253
geforce
  • 2,593
  • 3
  • 28
  • 44
  • 2
    not exactly the same thing as a completion handler but `viewDidAppear:animated:` let's you execute code each time your view controller appears on screen (`viewDidLoad` only the first time your view controller is loaded) – Moxy Aug 05 '12 at 15:17
  • @Moxy, do you mean `-(void)viewDidAppear:(BOOL)animated` – George Nov 27 '13 at 12:54
  • 3
    for **2018** ... really it's just this: https://stackoverflow.com/a/43017103/294884 – Fattie Jan 04 '18 at 22:29

9 Answers9

156

See par's answer for another and more up to date solution

UINavigationController animations are run with CoreAnimation, so it would make sense to encapsulate the code within CATransaction and thus set a completion block.

Swift:

For swift I suggest creating an extension as such

extension UINavigationController {

  public func pushViewController(viewController: UIViewController,
                                 animated: Bool,
                                 completion: @escaping (() -> Void)?) {
    CATransaction.begin()
    CATransaction.setCompletionBlock(completion)
    pushViewController(viewController, animated: animated)
    CATransaction.commit()
  }

}

Usage:

navigationController?.pushViewController(vc, animated: true) {
  // Animation done
}

Objective-C

Header:

#import <UIKit/UIKit.h>

@interface UINavigationController (CompletionHandler)

- (void)completionhandler_pushViewController:(UIViewController *)viewController
                                    animated:(BOOL)animated
                                  completion:(void (^)(void))completion;

@end

Implementation:

#import "UINavigationController+CompletionHandler.h"
#import <QuartzCore/QuartzCore.h>

@implementation UINavigationController (CompletionHandler)

- (void)completionhandler_pushViewController:(UIViewController *)viewController 
                                    animated:(BOOL)animated 
                                  completion:(void (^)(void))completion 
{
    [CATransaction begin];
    [CATransaction setCompletionBlock:completion];
    [self pushViewController:viewController animated:animated];
    [CATransaction commit];
}

@end
Alexander Vasenin
  • 11,437
  • 4
  • 42
  • 70
chrs
  • 5,906
  • 10
  • 43
  • 74
  • This is a far neater solution that subclassing. – Daniel Wood Oct 17 '14 at 09:22
  • Easy, accurate, compact, functional source provided. I wish all SO answers were this good. FWIW, popToRootViewControllerAnimated and popToViewController both work using the same technique. Just capture the NSArray and return it after the CATransaction commit. – Eli Burke Oct 28 '14 at 20:17
  • 1
    I believe (haven't tested) that this could provide inaccurate results if the presented view controller triggers animations inside it's viewDidLoad or viewWillAppear implementations. I think those animations will be started before pushViewController:animated: returns -- thus, the completion handler will not get called until the newly-triggered animations have finished. – Matt H. Nov 20 '14 at 22:15
  • If you *have* to use categories, you really should do something to defend yourself against namespace clashes. – Sam Mar 03 '15 at 19:27
  • 1
    @MattH. Did a couple tests this evening and it looks like when using `pushViewController:animated:` or `popViewController:animated`, the `viewDidLoad` and `viewDidAppear` calls happen in subsequent runloop cycles. So my impression is that even if those methods do invoke animations, they won't be part of transaction provided in the code example. Was that your concern? Because this solution is fabulously simple. – LeffelMania Mar 05 '15 at 04:17
  • 1
    Looking back at this question, I think in general the concerns mentioned by @MattH. and @LeffelMania do highlight a valid problem with this solution - it ultimately assumes the transaction will be completed after the push is complete, but the framework does not guarantee this behaviour. It is guaranteed than the view controller in question is shown in `didShowViewController` though. While this solution is fantastically simple, I would question its "future-proof-ness". Especially given the changes to view lifecycle callbacks that came with ios7/8 – Sam Mar 17 '15 at 21:49
  • 8
    This doesn't seem to work reliably on iOS 9 devices. See my or @par's answers below for an alternative – Mike Sprague Dec 16 '15 at 22:22
  • It does not work if you pop previous view controllers from navigation stack and then push new one. UINavigationControllerDelegate solution works always. At least for me. – b.zdybowicz Jan 22 '16 at 12:50
  • It should be a crime to write a Stack Overflow answer this good. – Adam Waite Oct 06 '16 at 09:47
  • And yet, there's an even better one: [par's answer](http://stackoverflow.com/a/33767837/255489) is much more reliable in my experience. – Zev Eisenberg Nov 01 '16 at 01:22
  • 1
    @ZevEisenberg definitely. My answer is dinosaur code in this world ~~2 years old – chrs Nov 01 '16 at 17:41
  • @chrs That's a very graceful reply. Your answer *was* the only way to do it when you posted, and I not only found it very helpful but used it myself until Apple gave us the ability to use the transition coordinator. – par Nov 04 '16 at 20:25
134

iOS 7+ Swift

Swift 4:

// 2018.10.30 par:
//   I've updated this answer with an asynchronous dispatch to the main queue
//   when we're called without animation. This really should have been in the
//   previous solutions I gave but I forgot to add it.
extension UINavigationController {
    public func pushViewController(
        _ viewController: UIViewController,
        animated: Bool,
        completion: @escaping () -> Void)
    {
        pushViewController(viewController, animated: animated)

        guard animated, let coordinator = transitionCoordinator else {
            DispatchQueue.main.async { completion() }
            return
        }

        coordinator.animate(alongsideTransition: nil) { _ in completion() }
    }

    func popViewController(
        animated: Bool,
        completion: @escaping () -> Void)
    {
        popViewController(animated: animated)

        guard animated, let coordinator = transitionCoordinator else {
            DispatchQueue.main.async { completion() }
            return
        }

        coordinator.animate(alongsideTransition: nil) { _ in completion() }
    }
}

EDIT: I've added a Swift 3 version of my original answer. In this version I've removed the example co-animation shown in the Swift 2 version as it seems to have confused a lot of people.

Swift 3:

import UIKit

// Swift 3 version, no co-animation (alongsideTransition parameter is nil)
extension UINavigationController {
    public func pushViewController(
        _ viewController: UIViewController,
        animated: Bool,
        completion: @escaping (Void) -> Void)
    {
        pushViewController(viewController, animated: animated)

        guard animated, let coordinator = transitionCoordinator else {
            completion()
            return
        }

        coordinator.animate(alongsideTransition: nil) { _ in completion() }
    }
}

Swift 2:

import UIKit

// Swift 2 Version, shows example co-animation (status bar update)
extension UINavigationController {
    public func pushViewController(
        viewController: UIViewController,
        animated: Bool,
        completion: Void -> Void)
    {
        pushViewController(viewController, animated: animated)

        guard animated, let coordinator = transitionCoordinator() else {
            completion()
            return
        }

        coordinator.animateAlongsideTransition(
            // pass nil here or do something animated if you'd like, e.g.:
            { context in
                viewController.setNeedsStatusBarAppearanceUpdate()
            },
            completion: { context in
                completion()
            }
        )
    }
}
par
  • 17,361
  • 4
  • 65
  • 80
  • 1
    Is there a particular reason why you're telling the vc to update it's status bar? This seems to work fine passing `nil` in as the animation block. – Mike Sprague Dec 17 '15 at 00:16
  • 2
    It's an example of something you might do as a parallel animation (the comment immediately above it indicates it's optional). Passing `nil` is a perfectly valid thing to do too. – par Dec 17 '15 at 00:21
  • 1
    @par, Should you be more defensive and call the completion when the `transitionCoordinator` is nil? – Aurelien Porte Mar 17 '16 at 13:59
  • @AurelienPorte That's a great catch and I'd say yes, you should. I'll update the answer. – par Mar 17 '16 at 17:20
  • on iOS 10, it seems like UIKit isn't creating a `transitionCoordinator` for the navigation controller during the push. – cbowns May 04 '17 at 01:21
  • 1
    @cbowns I'm not 100% sure about this as I haven't seen this happen, but if you don't see a `transitionCoordinator` then it's likely you're calling this function too early in the navigation controller's lifecycle. Wait at least until `viewWillAppear()` is called before trying to push a view controller with animation. – par Oct 30 '18 at 19:03
  • @par good tip. I don't have access to the app in question anymore, but that sounds about right. – cbowns Jan 01 '19 at 22:16
  • 1
    Not working for me for some cases. The value of animated is true but the transitionCoordinator value is nil. Dont know why is this happening. – Emtiyaj Ali Nov 25 '20 at 07:36
38

Based on par's answer (which was the only one that worked with iOS9), but simpler and with a missing else (which could have led to the completion never being called):

extension UINavigationController {
    func pushViewController(_ viewController: UIViewController, animated: Bool, completion: @escaping () -> Void) {
        pushViewController(viewController, animated: animated)

        if animated, let coordinator = transitionCoordinator {
            coordinator.animate(alongsideTransition: nil) { _ in
                completion()
            }
        } else {
            completion()
        }
    }

    func popViewController(animated: Bool, completion: @escaping () -> Void) {
        popViewController(animated: animated)

        if animated, let coordinator = transitionCoordinator {
            coordinator.animate(alongsideTransition: nil) { _ in
                completion()
            }
        } else {
            completion()
        }
    }
}
Daniel
  • 20,420
  • 10
  • 92
  • 149
  • Does not work for me. The transitionCoordinator is nil for me. – tcurdt Jun 15 '16 at 14:02
  • Works for me. Also this one is better then accepted one because animation completion not always the same as push completion. – Anton Plebanovich Sep 05 '18 at 16:17
  • You're missing a DispatchQueue.main.async for the non animated case. The contract of this method is that the completion handler is called asynchronously, you should not violated this because it can lead to subtle bugs. – Werner Altewischer Feb 13 '20 at 13:22
25

Currently the UINavigationController does not support this. But there's the UINavigationControllerDelegate that you can use.

An easy way to accomplish this is by subclassing UINavigationController and adding a completion block property:

@interface PbNavigationController : UINavigationController <UINavigationControllerDelegate>

@property (nonatomic,copy) dispatch_block_t completionBlock;

@end


@implementation PbNavigationController

- (id)initWithNibName:(NSString *)nibNameOrNil bundle:(NSBundle *)nibBundleOrNil {
    self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil];
    if (self) {
        self.delegate = self;
    }
    return self;
}

- (void)navigationController:(UINavigationController *)navigationController didShowViewController:(UIViewController *)viewController animated:(BOOL)animated {
    NSLog(@"didShowViewController:%@", viewController);

    if (self.completionBlock) {
        self.completionBlock();
        self.completionBlock = nil;
    }
}

@end

Before pushing the new view controller you would have to set the completion block:

UIViewController *vc = ...;
((PbNavigationController *)self.navigationController).completionBlock = ^ {
    NSLog(@"COMPLETED");
};
[self.navigationController pushViewController:vc animated:YES];

This new subclass can either be assigned in Interface Builder or be used programmatically like this:

PbNavigationController *nc = [[PbNavigationController alloc]initWithRootViewController:yourRootViewController];
KlimczakM
  • 12,576
  • 11
  • 64
  • 83
Klaas
  • 22,394
  • 11
  • 96
  • 107
  • 8
    Adding a list of completion blocks mapped to view controllers would probably make this most useful, and a new method, perhaps called `pushViewController:animated:completion:` would make this an elegant solution. – Hyperbole Feb 15 '13 at 16:49
  • 1
    NB for 2018 it's really just this ... https://stackoverflow.com/a/43017103/294884 – Fattie Jan 04 '18 at 22:35
10

Here is the Swift 4 version with the Pop.

extension UINavigationController {
    public func pushViewController(viewController: UIViewController,
                                   animated: Bool,
                                   completion: (() -> Void)?) {
        CATransaction.begin()
        CATransaction.setCompletionBlock(completion)
        pushViewController(viewController, animated: animated)
        CATransaction.commit()
    }

    public func popViewController(animated: Bool,
                                  completion: (() -> Void)?) {
        CATransaction.begin()
        CATransaction.setCompletionBlock(completion)
        popViewController(animated: animated)
        CATransaction.commit()
    }
}

Just in case someone else needs this.

Francois Nadeau
  • 7,023
  • 2
  • 49
  • 58
  • 4
    If you run a simple test on this, you'll find that the completion block fires before the animation is finished. So this probably doesn't provide what many are looking for. – horseshoe7 Mar 19 '19 at 09:16
9

To expand on @Klaas' answer (and as a result of this question) I've added completion blocks directly to the push method:

@interface PbNavigationController : UINavigationController <UINavigationControllerDelegate>

@property (nonatomic,copy) dispatch_block_t completionBlock;
@property (nonatomic,strong) UIViewController * pushedVC;

@end


@implementation PbNavigationController

- (id)initWithNibName:(NSString *)nibNameOrNil bundle:(NSBundle *)nibBundleOrNil {
    self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil];
    if (self) {
        self.delegate = self;
    }
    return self;
}

- (void)navigationController:(UINavigationController *)navigationController didShowViewController:(UIViewController *)viewController animated:(BOOL)animated {
    NSLog(@"didShowViewController:%@", viewController);

    if (self.completionBlock && self.pushedVC == viewController) {
        self.completionBlock();
    }
    self.completionBlock = nil;
    self.pushedVC = nil;
}

-(void)navigationController:(UINavigationController *)navigationController willShowViewController:(UIViewController *)viewController animated:(BOOL)animated {
    if (self.pushedVC != viewController) {
        self.pushedVC = nil;
        self.completionBlock = nil;
    }
}

-(void)pushViewController:(UIViewController *)viewController animated:(BOOL)animated completion:(dispatch_block_t)completion {
    self.pushedVC = viewController;
    self.completionBlock = completion;
    [self pushViewController:viewController animated:animated];
}

@end

To be used as follows:

UIViewController *vc = ...;
[(PbNavigationController *)self.navigationController pushViewController:vc animated:YES completion:^ {
    NSLog(@"COMPLETED");
}];
Community
  • 1
  • 1
Sam
  • 3,453
  • 1
  • 33
  • 57
  • `if... (self.pushedVC == viewController) {` is incorrect. You need to test equality among objects by using `isEqual:`, i.e., `[self.pushedVC isEqual:viewController]` – Evan R Jan 19 '16 at 19:45
  • @EvanR that is probably more technically correct yea. have you seen an error in comparing the instances the other way? – Sam Jan 20 '16 at 15:57
  • @Sam not specifically with this example (didn't implement it) but definitely in testing equality with other objects—see Apple's docs on this: https://developer.apple.com/library/ios/documentation/General/Conceptual/DevPedia-CocoaCore/ObjectComparison.html. Does your method of comparison always work in this case? – Evan R Jan 20 '16 at 17:01
  • I've not seen it not work or I would have changed my answer. As far as I know iOS doesn't do anything clever to recreate view controllers like android does with activities. but yes, `isEqual` would probably be more technically correct incase they ever did. – Sam Jan 20 '16 at 20:10
  • In this particular example you are interested in testing for identity rather than equality, so `==` is more correct. On top of that, unless `isEqual:` is explicitly overridden by the receiver, it would resort to identity comparison anyway. – gcbrueckmann Nov 20 '17 at 12:26
  • beware these answers are **incredibly out of date**. it's just a line of code now https://stackoverflow.com/a/43017103/294884 – Fattie Feb 23 '18 at 20:55
6

Since iOS 7.0,you can use UIViewControllerTransitionCoordinator to add a push completion block:

UINavigationController *nav = self.navigationController;
[nav pushViewController:vc animated:YES];

id<UIViewControllerTransitionCoordinator> coordinator = vc.transitionCoordinator;
[coordinator animateAlongsideTransition:^(id<UIViewControllerTransitionCoordinatorContext>  _Nonnull context) {

} completion:^(id<UIViewControllerTransitionCoordinatorContext>  _Nonnull context) {
    NSLog(@"push completed");
}];
wj2061
  • 6,778
  • 3
  • 36
  • 62
3

Swift 2.0

extension UINavigationController : UINavigationControllerDelegate {
    private struct AssociatedKeys {
        static var currentCompletioObjectHandle = "currentCompletioObjectHandle"
    }
    typealias Completion = @convention(block) (UIViewController)->()
    var completionBlock:Completion?{
        get{
            let chBlock = unsafeBitCast(objc_getAssociatedObject(self, &AssociatedKeys.currentCompletioObjectHandle), Completion.self)
            return chBlock as Completion
        }set{
            if let newValue = newValue {
                let newValueObj : AnyObject = unsafeBitCast(newValue, AnyObject.self)
                objc_setAssociatedObject(self, &AssociatedKeys.currentCompletioObjectHandle, newValueObj, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC)
            }
        }
    }
    func popToViewController(animated: Bool,comp:Completion){
        if (self.delegate == nil){
            self.delegate = self
        }
        completionBlock = comp
        self.popViewControllerAnimated(true)
    }
    func pushViewController(viewController: UIViewController, comp:Completion) {
        if (self.delegate == nil){
            self.delegate = self
        }
        completionBlock = comp
        self.pushViewController(viewController, animated: true)
    }

    public func navigationController(navigationController: UINavigationController, didShowViewController viewController: UIViewController, animated: Bool){
        if let comp = completionBlock{
            comp(viewController)
            completionBlock = nil
            self.delegate = nil
        }
    }
}
rahul_send89
  • 943
  • 8
  • 19
2

It takes a little more pipework to add this behavior and retain the ability to set an external delegate.

Here's a documented implementation that maintains delegate functionality:

LBXCompletingNavigationController

nzeltzer
  • 521
  • 4
  • 9