UIPageViewController gesture is calling viewControllerAfter: but doesn't animate

DavidAndroidDev picture DavidAndroidDev · Sep 21, 2012 · Viewed 9k times · Source

I have a really interesting issue with UIPageViewController.

My project is set up very similarly to the example Page Based Application template. Every now and then (but reproducible to a certain extent) a certain pan gesture will call out to -(UIViewController *)pageViewController:(UIPageViewController *)pageViewController viewControllerAfterViewController:(UIViewController *)viewController.

I return the viewcontroller for the next page, but a page flip animation is never ran and my delegate method is never called.

Here is the code for viewControllerAfterViewController

-(UIViewController *)pageViewController:(UIPageViewController *)pageViewController viewControllerAfterViewController:(UIViewController *)viewController
{
    PageDisplayViewController *vc = (PageDisplayViewController *)viewController;
    NSUInteger index = [self.pageFetchController.fetchedObjects indexOfObject:vc.page];
    if(index == (self.pageFetchController.fetchedObjects.count - 1)) return nil;
    return [self getViewControllerForIndex:(++index)];
}

Here is the getViewControllerForIndex:

-(PageDisplayViewController *)getViewControllerForIndex:(NSUInteger)index
{
    PageDisplayViewController *newVC = [self.storyboard instantiateViewControllerWithIdentifier:@"PageDisplayController"];
    newVC.page = [self.pageFetchController.fetchedObjects objectAtIndex:(index)];
    newVC.view.frame = CGRectMake(0, 0, 1024, 604);
    NSLog(@"%i", index);
    if(index == 0)
    {
        //We're moving to the first, animate the back button to be hidden.
        [UIView animateWithDuration:0.5 animations:^
        {
            self.backButton.alpha = 0.f;
        } completion:^(BOOL finished){
            self.backButton.hidden = YES;
        }];
    }
    else if(index == (self.pageFetchController.fetchedObjects.count - 1))
    {
        [UIView animateWithDuration:0.5 animations:^{
            self.nextButton.alpha = 0.f;
        } completion:^(BOOL finished){
            self.nextButton.hidden = YES;
        }];
    }
    else
    {
        BOOL eitherIsHidden = self.nextButton.hidden || self.backButton.hidden;
        if(eitherIsHidden)
        {
            [UIView animateWithDuration:0.5 animations:^{
                if(self.nextButton.hidden)
                {
                    self.nextButton.hidden = NO;
                    self.nextButton.alpha = 1.f;
                }
                if(self.backButton.hidden)
                {
                    self.backButton.hidden = NO;
                    self.backButton.alpha = 1.f;
                }
            }];
        }
    }
    return newVC;
}

Basically, I create the view controller, set it's data object, then fade a next/back button out depending on the index.

Delegate method

-(void)pageViewController:(UIPageViewController *)pageViewController didFinishAnimating:(BOOL)finished previousViewControllers:(NSArray *)previousViewControllers transitionCompleted:(BOOL)completed
{
    PageDisplayViewController *vc = [previousViewControllers lastObject];
    NSUInteger index = [self.pageFetchController.fetchedObjects indexOfObject:vc.page];

    if (!completed)
    {
        [self.pagePreviewView setCurrentIndex:index];
        NSLog(@"Animation Did not complete, reverting pagepreview");
    }
    else
    {
        PageDisplayViewController *curr = [pageViewController.viewControllers lastObject];
        NSUInteger i = [self.pageFetchController.fetchedObjects indexOfObject:curr.page];
        [self.pagePreviewView setCurrentIndex:i];
        NSLog(@"Animation compeleted, updating pagepreview. Index: %u", i);
    }
}

I only noticed this issue because randomly, my back button would reappear on screen. After tossing some NSLog() statements in there, I notice that my dataSource method gets called for an index of 1, but no animation ever plays or delegate gets called. Whats even scarier, is that if I try to pan the next page, index 1 gets called for AGAIN.

I fear this may be a bug with the UIPageViewController.

Answer

Lvsti picture Lvsti · Nov 22, 2012

Since I was still receiving mysterious crashes with the implementation in my first answer, I kept searching for a "good enough" solution which depends less on personal assumptions about the page view controller's (PVC) underlying behavior. Here is what I managed to come up with.

My former approach was kind of intrusive and was more of a workaround than an acceptable solution. Instead of fighting the PVC to force it to do what I thought it was supposed to do, it seems that it's better accept the facts that:

  • the pageViewController:viewControllerBeforeViewController: and pageViewController:viewControllerAfterViewController: methods can be called an arbitrary number of times by UIKit, and
  • there is absolutely no guarantee that either of these correspond to a paging animation, nor that they will be followed by a call to pageViewController:didFinishAnimating:previousViewControllers:transitionCompleted:

That means we cannot use the before/after methods as "animation-begin" (note, however, that didFinishAnimating still serves as "animation-end" event). So how do we know an animation has indeed started?

Depending on our needs, we may be interested in the following events:

  1. the user begins fiddling with the page: A good indicator for this is the before/after callbacks, or more precisely the first of them.

  2. first visual feedback of the page turning gesture: We can use KVO on the state property of the tap and pan gesture recognizers of the PVC. When a UIGestureRecognizerStateBegan value is observed for panning, we can be pretty sure that visual feedback will follow.

  3. the user finishes dragging the page around by releasing the touch: Again, KVO. When the UIGestureRecognizerStateRecognized value is reported either for panning or tapping, it is when the PVC is actually going to turn the page, so this may be used as "animation-begin".

  4. UIKit starts the paging animation: I have no idea how to get a direct feedback for this.

  5. UIKit concludes the paging animation: Piece of cake, just listen to pageViewController:didFinishAnimating:previousViewControllers:transitionCompleted:.

For KVO, just grab the gesture recognizers of the PVC as below:

@interface MyClass () <UIGestureRecognizerDelegate>
{
    UIPanGestureRecognizer* pvcPanGestureRecognizer;
    UITapGestureRecognizer* pvcTapGestureRecognizer;
}
...
for ( UIGestureRecognizer* recognizer in pageViewController.gestureRecognizers )
{
    if ( [recognizer isKindOfClass:[UIPanGestureRecognizer class]] )
    {
        pvcPanGestureRecognizer = (UIPanGestureRecognizer*)recognizer;
    }
    else if ( [recognizer isKindOfClass:[UITapGestureRecognizer class]] )
    {
        pvcTapGestureRecognizer = (UITapGestureRecognizer*)recognizer;
    }
}

Then register your class as observer for the state property:

[pvcPanGestureRecognizer addObserver:self
                          forKeyPath:@"state"
                             options:NSKeyValueObservingOptionNew
                             context:NULL];
[pvcTapGestureRecognizer addObserver:self
                          forKeyPath:@"state"
                             options:NSKeyValueObservingOptionNew
                             context:NULL];

And implement the usual callback:

- (void)observeValueForKeyPath:(NSString *)keyPath
        ofObject:(id)object
        change:(NSDictionary *)change
        context:(void *)context
{
    if ( [keyPath isEqualToString:@"state"] && (object == pvcPanGestureRecognizer || object == pvcTapGestureRecognizer) )
    {
        UIGestureRecognizerState state = [[change objectForKey:NSKeyValueChangeNewKey] intValue];
        switch (state)
        {
            case UIGestureRecognizerStateBegan:
            // trigger for visual feedback
            break;

            case UIGestureRecognizerStateRecognized:
            // trigger for animation-begin
            break;

            // ...
        }
    }
}

When you are done, don't forget to unsubscribe from those notifications, otherwise you may get leaks and strange crashes in your app:

[pvcPanGestureRecognizer removeObserver:self
                             forKeyPath:@"state"];
[pvcTapGestureRecognizer removeObserver:self
                             forKeyPath:@"state"];

That's all folks!