iOS: Is there currently a way to prevent two view controllers being pushed or popped at the same time?
Asked Answered
T

3

2

The only solution I have seen was an answer to a stackoverflow question. I posted the link below. The answer I am referring is the 5th one. It seems that some users have some problems with the solution however. I don't know if there is another category to prevent two controllers from being pushed at the same time. Any tips or suggestions are appreciated.

#import "UINavigationController+Consistent.h"
#import <objc/runtime.h>
/// This char is used to add storage for the isPushingViewController property.
 static char const * const ObjectTagKey = "ObjectTag";

 @interface UINavigationController ()
 @property (readwrite,getter = isViewTransitionInProgress) BOOL viewTransitionInProgress;

 @end

@implementation UINavigationController (Consistent)

- (void)setViewTransitionInProgress:(BOOL)property {
NSNumber *number = [NSNumber numberWithBool:property];
objc_setAssociatedObject(self, ObjectTagKey, number , OBJC_ASSOCIATION_RETAIN);
}


- (BOOL)isViewTransitionInProgress {
NSNumber *number = objc_getAssociatedObject(self, ObjectTagKey);

return [number boolValue];
}


 #pragma mark - Intercept Pop, Push, PopToRootVC
 /// @name Intercept Pop, Push, PopToRootVC

 - (NSArray *)safePopToRootViewControllerAnimated:(BOOL)animated {
if (self.viewTransitionInProgress) return nil;
if (animated) {
    self.viewTransitionInProgress = YES;
}
//-- This is not a recursion, due to method swizzling the call below calls the original  method.
return [self safePopToRootViewControllerAnimated:animated];

 }


  - (NSArray *)safePopToViewController:(UIViewController *)viewController animated:(BOOL)animated {
if (self.viewTransitionInProgress) return nil;
if (animated) {
    self.viewTransitionInProgress = YES;
   }
//-- This is not a recursion, due to method swizzling the call below calls the original  method.
return [self safePopToViewController:viewController animated:animated];
   }


 - (UIViewController *)safePopViewControllerAnimated:(BOOL)animated {
if (self.viewTransitionInProgress) return nil;
if (animated) {
    self.viewTransitionInProgress = YES;
}
//-- This is not a recursion, due to method swizzling the call below calls the original  method.
return [self safePopViewControllerAnimated:animated];
 }



  - (void)safePushViewController:(UIViewController *)viewController animated:(BOOL)animated {
self.delegate = self;
//-- If we are already pushing a view controller, we dont push another one.
if (self.isViewTransitionInProgress == NO) {
    //-- This is not a recursion, due to method swizzling the call below calls the original  method.
    [self safePushViewController:viewController animated:animated];
    if (animated) {
        self.viewTransitionInProgress = YES;
    }
    }
    }


// This is confirmed to be App Store safe.
// If you feel uncomfortable to use Private API, you could also use the delegate method navigationController:didShowViewController:animated:.
- (void)safeDidShowViewController:(UIViewController *)viewController animated:(BOOL)animated {
//-- This is not a recursion. Due to method swizzling this is calling the original method.
[self safeDidShowViewController:viewController animated:animated];
self.viewTransitionInProgress = NO;
 }


// If the user doesnt complete the swipe-to-go-back gesture, we need to intercept it and set the flag to NO again.
 - (void)navigationController:(UINavigationController *)navigationController willShowViewController:(UIViewController *)viewController animated:(BOOL)animated {
id<UIViewControllerTransitionCoordinator> tc = navigationController.topViewController.transitionCoordinator;
[tc notifyWhenInteractionEndsUsingBlock:^(id<UIViewControllerTransitionCoordinatorContext> context) {
    self.viewTransitionInProgress = NO;
    //--Reenable swipe back gesture.
    self.interactivePopGestureRecognizer.delegate = (id<UIGestureRecognizerDelegate>)viewController;
    [self.interactivePopGestureRecognizer setEnabled:YES];
}];
//-- Method swizzling wont work in the case of a delegate so:
//-- forward this method to the original delegate if there is one different than ourselves.
if (navigationController.delegate != self) {
    [navigationController.delegate navigationController:navigationController
                                 willShowViewController:viewController
                                               animated:animated];
}
}


  + (void)load {
//-- Exchange the original implementation with our custom one.
method_exchangeImplementations(class_getInstanceMethod(self,  @selector(pushViewController:animated:)), class_getInstanceMethod(self, @selector(safePushViewController:animated:)));
method_exchangeImplementations(class_getInstanceMethod(self, @selector(didShowViewController:animated:)), class_getInstanceMethod(self, @selector(safeDidShowViewController:animated:)));
method_exchangeImplementations(class_getInstanceMethod(self, @selector(popViewControllerAnimated:)), class_getInstanceMethod(self, @selector(safePopViewControllerAnimated:)));
method_exchangeImplementations(class_getInstanceMethod(self, @selector(popToRootViewControllerAnimated:)), class_getInstanceMethod(self, @selector(safePopToRootViewControllerAnimated:)));
method_exchangeImplementations(class_getInstanceMethod(self, @selector(popToViewController:animated:)), class_getInstanceMethod(self, @selector(safePopToViewController:animated:)));
 }

 @end

iOS app error - Can't add self as subview

Tuinenga answered 24/11, 2014 at 18:45 Comment(1)
As long as you're using a navigation stack it seems as if you should be able to confirm that the view controller is the last in the stack before popping and that it's preceded by its expected predecessors before pushing.Oculomotor
O
3

Updated answer:

I prefer this solution by nonamelive on Github to what I originally posted: https://gist.github.com/nonamelive/9334458. By subclassing the UINavigationController and taking advantage of the UINavigationControllerDelegate, you can establish when a transition is happening, prevent other transitions from happening during that transition, and do so all within the same class. Here's an update of nonamelive's solution which excludes the private API:

#import "NavController.h"

@interface NavController ()

@property (nonatomic, assign) BOOL shouldIgnorePushingViewControllers;

@end

@implementation NavController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
}

- (void)didReceiveMemoryWarning {
    [super didReceiveMemoryWarning];
    // Dispose of any resources that can be recreated.
}

- (void)pushViewController:(UIViewController *)viewController animated:(BOOL)animated
{
    if (!self.shouldIgnorePushingViewControllers)
    {
        [super pushViewController:viewController animated:animated];
    }
    self.shouldIgnorePushingViewControllers = YES;
}

- (void)navigationController:(UINavigationController *)navigationController didShowViewController:(UIViewController *)viewController animated:(BOOL)animated {
    self.shouldIgnorePushingViewControllers = NO;
}

@end

Previous answer:

Problem with this Previous Answer: isBeingPresented and isBeingDismissed only work in viewDidLoad: or viewDidApper:

Although I haven't tested this myself, here is a suggestion.

Since you're using a UINavigationController, you can access the contents of your navigation stack, like so:

NSArray *viewControllers = self.navigationController.viewControllers;

And through that array of view controllers, you can access some or all relevant indices if need be.

Luckily, two especially convenient methods were introduced in iOS 5: isBeingPresented and isBeingDismissed which return "YES" if the view controller is in the process of being presented or being dismissed, respectively; "NO" otherwise.

So, for example, here's one approach:

NSArray *viewControllers = self.navigationController.viewControllers;

for (UIViewController *viewController in viewControllers) {

    if (viewController.isBeingPresented || viewController.isBeingDismissed) {
        // In this case when a pop or push is already in progress, don't perform
        // a pop or push on the current view controller. Perhaps return to this
        // method after a delay to check this conditional again.
        return;
    }
}

// Else if you make it through the loop uninterrupted, perform push or pop
// of the current view controller.

In actuality, you probably won't have to loop through every view controller on the stack, but perhaps this suggestion will help set you off on the right foot.

Oculomotor answered 24/11, 2014 at 19:57 Comment(6)
I voted up before testing, but this actually doesn't work. if called right after pushViewController:animated:, isBeingPresented and isBeingDismissed return false...Vernievernier
@Vernievernier The docs specifically state that isBeingPresented "Returns a Boolean value that indicates whether the view controller is in the process of being presented by one of its ancestors." and should return true if it's in the process of being presented... Could you share your specific code? Perhaps it's a threading issue...Oculomotor
I just found out why. The doc also says : "This method returns YES only when called from inside the viewWillAppear: and viewDidAppear: methods". Not very practical for what we are trying to do here. I ended up implementing something similar to this : gist.github.com/nonamelive/9334458 using method swizzling instead of subclassing.Vernievernier
@Vernievernier I like the idea behind the code you linked to and I like that you came up with a solution that doesn't involve subclassing (maybe you should post it here). But instead of method swizzling, I think I'm going to update my solution to use NSNotifications to indicate the start and end of each relevant push and prevent one view from pushing when the other view is in between start and end notifications. I'll maybe the updates soon...Oculomotor
It would indeed look cleaner with notifications. I will post an answer with my code.Vernievernier
@Vernievernier Ha I changed my mind... While working out the code, I realized that other than the fact that it uses an unnecessary private API, that github answer is almost perfect. It just makes so much sense to subclass the navigation controller then prevent transitions within that navigation controller subclass while the transitions in progress. I'll update my answer with a version of that code.Oculomotor
V
1

Here is my approach, using a UINavigationController category and method swizzling. The method -[UINavigationController didShowViewController:animated:] is private, so although it has been reported safe to use, use at you own risks.

Credits goes to this answer for the idea and NSHipster for the method swizzling code. This answer also has an interesting approach.

//
//  UINavigationController+Additions.h
//

@interface UINavigationController (Additions)

@property (nonatomic, getter = isViewTransitionInProgress) BOOL viewTransitionInProgress;

@end


//
//  UINavigationController+Additions.m
//

#import "UINavigationController+Additions.h"
#import <objc/runtime.h>

static void *UINavigationControllerViewTransitionInProgressKey = &UINavigationControllerViewTransitionInProgressKey;

@interface UINavigationController ()
// Private method, use at your own risk.
- (void)didShowViewController:(UIViewController *)viewController animated:(BOOL)animated;
@end


@implementation UINavigationController (Additions)

+ (void)load
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Class class = [self class];

        SEL originalSelector1 = @selector(pushViewController:animated:);
        SEL swizzledSelector1 = @selector(zwizzledForViewTransitionInProgress_pushViewController:animated:);

        Method originalMethod1 = class_getInstanceMethod(class, originalSelector1);
        Method swizzledMethod1 = class_getInstanceMethod(class, swizzledSelector1);

        BOOL didAddMethod1 = class_addMethod(class, originalSelector1, method_getImplementation(swizzledMethod1), method_getTypeEncoding(swizzledMethod1));

        if (didAddMethod1) {
            class_replaceMethod(class, swizzledSelector1, method_getImplementation(originalMethod1), method_getTypeEncoding(originalMethod1));
        } else {
            method_exchangeImplementations(originalMethod1, swizzledMethod1);
        }

        SEL originalSelector2 = @selector(didShowViewController:animated:);
        SEL swizzledSelector2 = @selector(zwizzledForViewTransitionInProgress_didShowViewController:animated:);

        Method originalMethod2 = class_getInstanceMethod(class, originalSelector2);
        Method swizzledMethod2 = class_getInstanceMethod(class, swizzledSelector2);

        BOOL didAddMethod2 = class_addMethod(class, originalSelector2, method_getImplementation(swizzledMethod2), method_getTypeEncoding(swizzledMethod2));

        if (didAddMethod2) {
            class_replaceMethod(class, swizzledSelector2, method_getImplementation(originalMethod2), method_getTypeEncoding(originalMethod2));
        } else {
            method_exchangeImplementations(originalMethod2, swizzledMethod2);
        }

    });
}

- (void)zwizzledForViewTransitionInProgress_pushViewController:(UIViewController *)viewController animated:(BOOL)animated
{
    if (self.viewTransitionInProgress) {
        LogWarning(@"Pushing a view controller while an other view transition is in progress. Aborting.");
    } else {
        self.viewTransitionInProgress = YES;
        [self zwizzledForViewTransitionInProgress_pushViewController:viewController animated:animated];
    }
}

- (void)zwizzledForViewTransitionInProgress_didShowViewController:(UIViewController *)viewController animated:(BOOL)animated
{
    [self zwizzledForViewTransitionInProgress_didShowViewController:viewController animated:YES];
    self.viewTransitionInProgress = NO;
}

- (void)setViewTransitionInProgress:(BOOL)viewTransitionInProgress
{
    NSNumber *boolValue = [NSNumber numberWithBool:viewTransitionInProgress];
    objc_setAssociatedObject(self, UINavigationControllerViewTransitionInProgressKey, boolValue, OBJC_ASSOCIATION_RETAIN);
}

- (BOOL)isViewTransitionInProgress
{
    NSNumber *viewTransitionInProgress = objc_getAssociatedObject(self, UINavigationControllerViewTransitionInProgressKey);
    return [viewTransitionInProgress boolValue];
}

@end
Vernievernier answered 13/4, 2015 at 17:59 Comment(0)
C
1

Inspired by @Lindsey Scott answer I created UINavigationController subclass. The advantage of my solution is that it also handles popping, and you can actually execute all requests after each other without problems(this is controlled via acceptConflictingCommands flag).

MyNavigationController.h

#import <UIKit/UIKit.h>

@interface MyNavigationController : UINavigationController

@property(nonatomic, assign) BOOL acceptConflictingCommands;

@end

MyNavigationController.m

#import "MyNavigationController.h"

@interface MyNavigationController ()<UINavigationControllerDelegate>

@property(nonatomic, assign) BOOL shouldIgnoreStackRequests;
@property(nonatomic, strong) NSMutableArray* waitingCommands;

@end

@implementation MyNavigationController

-(instancetype)init
{
    if( self = [super init] )
    {
        self.delegate = self;
        _waitingCommands = [NSMutableArray new];
    }

    return self;
}

-(instancetype)initWithRootViewController:(UIViewController *)rootViewController
{
    if( self = [super initWithRootViewController:rootViewController] )
    {
        self.delegate = self;
        _waitingCommands = [NSMutableArray new];
        _acceptConflictingCommands = YES;
    }

    return self;
}

-(void)pushViewController:(UIViewController *)viewController animated:(BOOL)animated
{
    if( !_shouldIgnoreStackRequests )
    {
        [super pushViewController:viewController animated:animated];

        _shouldIgnoreStackRequests = YES;
    }
    else if (_acceptConflictingCommands)
    {
        __weak typeof(self) weakSelf = self;

        //store and push it after current transition ends
        [_waitingCommands addObject:^{

            id strongSelf = weakSelf;

            [strongSelf pushViewController:viewController animated:animated];

        }];
    }

}

-(UIViewController *)popViewControllerAnimated:(BOOL)animated
{
    __block UIViewController* popedController = nil;

    if( 1 < self.viewControllers.count )
    {
        if( !_shouldIgnoreStackRequests )
        {
            popedController = [super popViewControllerAnimated:animated];

            _shouldIgnoreStackRequests = YES;
        }
        else if( _acceptConflictingCommands )
        {
            __weak typeof(self) weakSelf = self;

            [_waitingCommands addObject:^{

                id strongSelf = weakSelf;

                popedController = [strongSelf popViewControllerAnimated:animated];

            }];
        }
    }

    return popedController;
}

#pragma mark - uinavigationcontroller delegate
- (void)navigationController:(UINavigationController *)navigationController didShowViewController:(UIViewController *)viewController animated:(BOOL)animated
{
    _shouldIgnoreStackRequests = NO;

    if( 0 < _waitingCommands.count )
    {
        void(^waitingAction)() = _waitingCommands.lastObject;
        [_waitingCommands removeLastObject];
        waitingAction();
    }
}

@end

Of course you can change default value of acceptConflictingCommands or control it externally.

If your code happens to use popToRootViewController, setViewControllers:animated: and/or popToViewController you have to override them in the same manner to make sure they won't brake navigation stack.

Cumings answered 22/4, 2015 at 14:10 Comment(2)
It seems that there is a bug. When a user pop current vc by using swipe from edge gesture and then cancel that gesture during the movement, _shouldIgnoreStackRequests is set to YES and never set back.Module
@Cumings Is calling popToRootViewController:animated: safe or it should also be reflected in this code snippet?Barque

© 2022 - 2024 — McMap. All rights reserved.