22

I am having a weird problem with UINavigationBar's title in an app when interactivePopGestureRecognizer comes into play. I have made a demo app to showcase this bug.

Setup:

  • The rootViewController is a UINavigationController.
  • FirstViewController has the navigation bar hidden, and interactivePopGestureRecognizer.enabled = NO;
  • Second and ThirdViewControllers have the navigation bar visible and the popgesture enabled.

Bug:

The bug occurs when going back from the Second to the First view using the popgesture. If you pull the second view halfway and then go back to the second view, the navigation title will show "Second View" (as expected).But when you go to the Third view, the title will not change to "Third View". And then on clicking the back button of the Third view, the navbar will get messed up.

Please check out my demo app. Any help explaining why this bug is happening will be appreciated. Thanks!

aksh1t
  • 5,410
  • 1
  • 37
  • 55
  • I've provided three solutions (see my answer below) in a fork to your github example, and issued a pull request so you can try them out. After pulling (and pulling to your own machine), use `git checkout` to check out each of the three solution commits and try them in turn so you can examine the code and see how they work. – matt Apr 26 '14 at 17:48

5 Answers5

51

Remove Red Herrings

First of all, your example can be greatly simplified. You should delete all the viewDidLoad stuff, as it is a complete red herring and just complicates the issue. You should not be playing around with the pop gesture recognizer delegate on every change of view controller; and turning the pop gesture recognizer off and on is irrelevant to the example (it is on by default, and should just be left on for this example). So delete this kind of thing in all three view controllers:

- (void)viewDidLoad {
    [super viewDidLoad];
    if ([self.navigationController respondsToSelector:@selector(interactivePopGestureRecognizer)]) {
        self.navigationController.interactivePopGestureRecognizer.enabled = NO;
        self.navigationController.interactivePopGestureRecognizer.delegate = self;
    }
}

(Don't delete the code that sets self.title, though you could have made things even simpler by doing that in the xib file for each view controller.)

You can also get rid of other unused methods throughout, such as the init... methods and memory alert methods.

Another issue, by the way, is that you have forgotten to call super in your implementations of viewWillAppear:. It is required that you do this. I don't think that affects the bug, but it is well to obey all the rules before you start trying to track these things down.

Now the bug still happens but we have much simpler code, so we can start to isolate the issue.

How The Pop Gesture Works

So what's the cause of the problem? I think the most obvious way to understand it is to realize how the pop gesture works. This is an interactive view controller transition animation. That's right - it's an animation. The way it works is that the pop animation (slide from the left) is attached to the superview layer, but with a speed of 0 so that it doesn't actually run. As the gesture proceeds, the timeOffset of the layer is constantly being updated, so that the corresponding "frame" of the animation appears. Thus it looks like you are dragging the view, but you are not; you are just making a gesture, and animation is proceeding at the same rate and to the same degree. I have explained this mechanism in this answer: https://stackoverflow.com/a/22677298/341994

Most important (pay attention to this part), if the gesture is abandoned in the middle (which it almost certainly will be), a decision is made as to whether the gesture is more than half-way completed, and based on this, either the animation is rapidly played to the end (i.e. the speed is set to something like 3) or the animation is run backwards to the start (i.e. the speed is set to something like -3).

Solutions And Why They Work

Now let's talk about the bug. There are two complications here that you've accidentally banged into:

  • As the pop animation and pop gesture begin, viewWillAppear: is called for the previous view controller even though the view may not ultimately appear (because this is an interactive gesture and the gesture may be cancelled). This can be a serious issue if you are used to the assumption that viewWillAppear: is always followed by the view actually taking over the screen (and viewDidAppear: being called), because this is a situation in which those things might not happen. (As Apple says in the WWDC 2013 videos, "view will appear" actually means "view might appear".)

  • There is a secondary set of animations, namely, everything connected with the navigation bar - the change of title (it is supposed to fade into view) and, in this case, the change between not hidden and hidden. The runtime is trying to coordinate the secondary set of animations with the sliding view animation. But you have made that difficult by calling for no animation when the bar is hidden or shown.

Thus, as you've already been told, one solution is to change animated:NO to animated:YES throughout your code. This way, the showing and hiding of the navigation bar is ordered up as part of the animation. Therefore, when the gesture is cancelled and the animation is run backwards to the start, the showing/hiding of the navigation is also run backwards to the start - the two things are now staying coordinated.

But what if you really don't want to make that change? Well, another solution is to change viewWillAppear: to viewDidAppear: throughout. As I've already said, viewWillAppear: is called at the start of the animation, even if the gesture won't be completed, which is causing things to get out of whack. But viewDidAppear: is called only if the gesture is completed (not canceled) and when the animation is already over.

Which of those two solutions do I prefer? Neither of them! They both force you to make changes you don't want to make. The real solution, it seems to me, is to use the transition coordinator.

The Transition Coordinator

The transition coordinator is an object supplied by the system for this very purpose, i.e., to detect that we're involved in an interactive transition and to behave differently depending on whether it is canceled or not.

Concentrate just on the OneViewController implementation of viewWillAppear:. This is where things are getting messed up. When you're in TwoViewController and you start the pan gesture from the left, OneViewController's viewWillAppear: is being called. But then you cancel, letting go of the gesture without completing it. In just that one case, you want not to do what you were doing in OneViewController's viewWillAppear:. And that is exactly what the transition coordinator allows you to do.

Here, then, is a rewrite of OneViewController's viewWillAppear:. This fixes the problem without your having to make any other changes:

-(void)viewWillAppear:(BOOL)animated{
    [super viewWillAppear:animated];
    id<UIViewControllerTransitionCoordinator> tc = self.transitionCoordinator;
    if (tc && [tc initiallyInteractive]) {
        [tc notifyWhenInteractionEndsUsingBlock:
         ^(id<UIViewControllerTransitionCoordinatorContext> context) {
             if ([context isCancelled]) {
                 // do nothing!
             } else { // not cancelled, do it
                 [self.navigationController setNavigationBarHidden:YES animated:NO];
             }
         }];
    } else { // not interactive, do it
        [self.navigationController setNavigationBarHidden:YES animated:NO];
    }
}
Community
  • 1
  • 1
matt
  • 515,959
  • 87
  • 875
  • 1,141
  • Wow, this is an amazingly detailed answer. Just what I was looking for! Thanks, @matt! – aksh1t Apr 28 '14 at 08:15
  • Using the transition coordinator, fixed the mess-up we had in our app when moving form screens that had their navigation bar hidden and those that didnt – Avi Cohen Dec 02 '14 at 14:30
  • 1
    I'd love to combine this answer with http://stackoverflow.com/a/2406167/354018 but I still do not come around the bug when cancelling the pop gesture before the navigation bar hide animation is finished (see my comment in the linked answer). I'm stuck, any idea how to do it? – fabb Apr 24 '15 at 09:34
  • any help here would be great @matt: https://stackoverflow.com/questions/48954413/how-to-enable-swipe-gesture-when-navigation-bar-is-hidden – user7097242 Feb 23 '18 at 18:53
  • Man, I would marry you right now. You solved (and explained perfectly its cause) a problem that has been driving me crazy for months now (it is a huge banking app with lots of layers but this is really the root of our issue at the end). Thank you! – rmvz3 Feb 23 '22 at 23:54
6

The fix is simple , but I don't have any explanation at the moment why this is happening.

One your OneViewController change your viewWillAppear to ,

-(void)viewWillAppear:(BOOL)animated{
   // [self.navigationController setNavigationBarHidden:YES animated:NO];
    self.navigationController.navigationBar.hidden = YES;
}

and on the second and third view controllers change it to,

 -(void)viewWillAppear:(BOOL)animated{
         //[self.navigationController setNavigationBarHidden:NO animated:NO];
         self.navigationController.navigationBar.hidden = NO;
    }

Strange but this will fix the issue when we directly use the hidden property of the UINavigationBar.

rustylepord
  • 5,681
  • 6
  • 36
  • 49
  • Using the set method with animated parameter YES will fix this issue too! [self.navigationController setNavigationBarHidden:YES animated:YES]; – Sergey Neskoromny Apr 26 '14 at 08:37
  • There is nothing strange, the navigation bar is shared between presented view controllers. Timing view will appear/view will disappear methods correctly, can create the desired effect. – Léo Natan Apr 26 '14 at 17:55
  • Use the animated parameter is better. – Míng Feb 25 '16 at 02:33
  • This is not a preferable approach. With this approach when i swipe back to previous viewcontroller, on the middle way i can see that bar is dissappearing and appearing partially. If the Nav bar show/hide property is changed in both Controllers. – Ammar Mujeeb Sep 26 '16 at 09:48
1

I don't know how do you make "FirstViewController has the navigation bar hidden".

I have the same problem, and I fixed it by replacing

self.navigationController.navigationBarHidden = YES / NO;

by

[self.navigationController setNavigationBarHidden:YES / NO animated:animated];
Míng
  • 2,500
  • 6
  • 32
  • 48
1

I gave up trying to make this work used my own swipe recognizer that pops the navigation stack:

override func viewDidLoad() {
    super.viewDidLoad()

    // disable system swipe back gesture and add our own
    navigationController?.interactivePopGestureRecognizer?.enabled = false
    let swipeBackGestureRecognizer = UISwipeGestureRecognizer(target: self, action: "swipeBackAction:")
    swipeBackGestureRecognizer.direction = UISwipeGestureRecognizerDirection.Right
    tableView.addGestureRecognizer(swipeBackGestureRecognizer)
}

func swipeBackAction(sender: UISwipeGestureRecognizer) {

    navigationController?.popViewControllerAnimated(true)
}
  • Disable the system interactivePopGestureRecognizer
  • Create your own UISwipeGestureRecognizer with a Right direction
  • Pop the navigation stack animated when he swipe is detected
Michael Peterson
  • 10,383
  • 3
  • 54
  • 51
0

Here's what fixed it for me (Swift)

1st view controller:

override func viewWillAppear(animated: Bool) {
    super.viewWillAppear(animated)
    self.navigationController?.setNavigationBarHidden(true, animated: animated)
}

2nd and 3rd view controllers:

override func viewWillAppear(animated: Bool) {
    super.viewWillAppear(animated)
    self.navigationController?.setNavigationBarHidden(false, animated: animated)
}
AmitP
  • 5,353
  • 4
  • 35
  • 27