Completion handler for UINavigationController "pushViewController:animated"?
Asked Answered
M

10

130

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: ?

Mola answered 28/3, 2012 at 11:55 Comment(3)
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)Quasimodo
@Moxy, do you mean -(void)viewDidAppear:(BOOL)animatedSeverally
for 2018 ... really it's just this: https://mcmap.net/q/167925/-is-it-possible-to-push-a-view-controller-with-a-completion-blockAleece
O
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
Osbert answered 10/8, 2014 at 15:37 Comment(12)
This is a far neater solution that subclassing.Verso
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.Maldonado
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.Atronna
If you have to use categories, you really should do something to defend yourself against namespace clashes.Taintless
@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.Mudcat
Looking back at this question, I think in general the concerns mentioned by @MattH. and @Mudcat 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/8Taintless
This doesn't seem to work reliably on iOS 9 devices. See my or @par's answers below for an alternativePettway
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.Dilettante
It should be a crime to write a Stack Overflow answer this good.Stress
And yet, there's an even better one: par's answer is much more reliable in my experience.Khiva
@ZevEisenberg definitely. My answer is dinosaur code in this world ~~2 years oldOsbert
@Osbert 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.Youngman
Y
137

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()
            }
        )
    }
}
Youngman answered 17/11, 2015 at 22:0 Comment(9)
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.Pettway
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.Youngman
@par, Should you be more defensive and call the completion when the transitionCoordinator is nil?Grot
@AurelienPorte That's a great catch and I'd say yes, you should. I'll update the answer.Youngman
on iOS 10, it seems like UIKit isn't creating a transitionCoordinator for the navigation controller during the push.Edmundson
@Edmundson 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.Youngman
@Youngman good tip. I don't have access to the app in question anymore, but that sounds about right.Edmundson
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.Scottie
In my case it not work, but work with CATransaction. With popToRootViewController method.Cilia
S
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()
        }
    }
}
Shrubby answered 28/1, 2016 at 15:2 Comment(3)
Does not work for me. The transitionCoordinator is nil for me.Mathewmathews
Works for me. Also this one is better then accepted one because animation completion not always the same as push completion.Smalley
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.Quadragesimal
S
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];
Salesgirl answered 15/2, 2013 at 16:44 Comment(2)
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.Adila
NB for 2018 it's really just this ... https://mcmap.net/q/167925/-is-it-possible-to-push-a-view-controller-with-a-completion-blockAleece
C
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.

Copperplate answered 6/2, 2018 at 20:36 Comment(1)
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.Maribeth
T
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");
}];
Taintless answered 26/2, 2014 at 15:14 Comment(6)
if... (self.pushedVC == viewController) { is incorrect. You need to test equality among objects by using isEqual:, i.e., [self.pushedVC isEqual:viewController]Worcestershire
@EvanR that is probably more technically correct yea. have you seen an error in comparing the instances the other way?Taintless
@Taintless not specifically with this example (didn't implement it) but definitely in testing equality with other objects—see Apple's docs on this: developer.apple.com/library/ios/documentation/General/…. Does your method of comparison always work in this case?Worcestershire
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.Taintless
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.Punctuality
beware these answers are incredibly out of date. it's just a line of code now https://mcmap.net/q/167925/-is-it-possible-to-push-a-view-controller-with-a-completion-blockAleece
B
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");
}];
Brack answered 18/8, 2016 at 20:41 Comment(1)
This is not quite the same thing as UINavigationController push, pop, etc.Tiffanietiffanle
W
4

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
        }
    }
}
Witted answered 24/12, 2015 at 6:46 Comment(0)
G
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

Guimar answered 2/3, 2014 at 1:36 Comment(0)
C
0

I've write an extension but in my case the call of methods are very haotic, so this way is not suitable for me. Anywhere, maybe somebody would need it.

private var UINavigationControllerCompletionKey = 0
private var UINavigationControllerDelegateKey = 1

extension UINavigationController {
    fileprivate var completion: EmptyClosure? {
        get {
            return objc_getAssociatedObject(self, &UINavigationControllerCompletionKey) as? EmptyClosure
        }
        set(newValue) {
            objc_setAssociatedObject(self, &UINavigationControllerCompletionKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
        }
    }

    /// Need to save delegate in order to set it back after completion
    fileprivate var previousDelegateCache: UINavigationControllerDelegate? {
        get {
            return objc_getAssociatedObject(self, &UINavigationControllerDelegateKey) as? UINavigationControllerDelegate
        }
        set(newValue) {
            objc_setAssociatedObject(self, &UINavigationControllerDelegateKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
        }
    }

    @discardableResult
    public func popViewController(animated: Bool, completion: @escaping EmptyClosure) -> UIViewController? {
        self.completion = completion
        previousDelegateCache = delegate
        delegate = self
        return self.popViewController(animated: animated)
    }

    @discardableResult
    public func popToRootViewController(animated: Bool, completion: @escaping EmptyClosure) -> [UIViewController]? {
        self.completion = completion
        previousDelegateCache = delegate
        delegate = self
        return self.popToRootViewController(animated: animated)
    }

    public func pushViewController(_ viewController: UIViewController, animated: Bool, completion: EmptyClosure?) {
        self.completion = completion
        previousDelegateCache = delegate
        delegate = self
        return self.pushViewController(viewController, animated: animated)
    }
}

extension UINavigationController: UINavigationControllerDelegate {
    public func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
        completion?()
        completion = nil
        delegate = previousDelegateCache
        previousDelegateCache = nil
    }
}  

Also check interesting repo with buffered NavigationController. https://github.com/Plasma/BufferedNavigationController/tree/master Although the problem with delegate is still exists in it.

Cilia answered 31/1, 2024 at 12:10 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.