8

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.

DavidAndroidDev
  • 2,371
  • 3
  • 25
  • 41

4 Answers4

8

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!

Lvsti
  • 1,525
  • 15
  • 15
4

Please look at my other answer in the first place, this one has serious flaws but I'm leaving it here as it might still help someone.


First off, a disclaimer: The following solution is a HACK. It does work in the environment I tested but there is no guarantee that it works in yours nor that it won't be broken by the next update. So proceed with care.

TL;DR: grab the UIPanGestureRecognizer of the UIPageViewController and hijack its delegate calls but keep forwarding them to the original target.

Longer version:

My findings on the issue: the UIPageViewController shipped in iOS 6 is different in behavior to the one in iOS 5 in that it may call the pageViewController:viewControllerBeforeViewController: on its datasource even if there is no page turning going on in any sense (read: no tap, swipe, or valid direction-matching panning has been recognized). This, of course, breaks our former assumption that the before/after calls are equivalent to an "animation begin" trigger and are consistently followed by a pageViewController:didFinishAnimating:previousViewControllers:transitionCompleted: call to the delegate. (Eventually this is a bold assumption to make but I guess I was not alone with that.)

I found out that the extra calls to the datasource are likely to happen when the default UIPanGestureRecognizer on the page view controller starts to recognize a pan gesture that in the end doesn't match the direction of the controller (e.g. vertical panning in a horizontally paging PVC). Interestingly enough, in my environment it was always the "before" method which got hit, never the "after". Others suggested interfereing with the gesture recognizer's delegate but that didn't work for me the way it was described there so I kept experimenting.

Finally I found a workaround. First we grab the pan gesture recognizer of the page view controller:

@interface MyClass () <UIGestureRecognizerDelegate>
{
    UIPanGestureRecognizer* pvcPanGestureRecognizer;
    id<UIGestureRecognizerDelegate> pvcPanGestureRecognizerDelegate;
}
...
for ( UIGestureRecognizer* recognizer in pageViewController.gestureRecognizers )
{
    if ( [recognizer isKindOfClass:[UIPanGestureRecognizer class]] )
    {
        pvcPanGestureRecognizer = (UIPanGestureRecognizer*)recognizer;
        pvcPanGestureRecognizerDelegate = pvcPanGestureRecognizer.delegate;
        pvcPanGestureRecognizer.delegate = self;
        break;
    }
}

Then we implement the UIGestureRecognizerDelegate protocol in our class:

- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch
{
    if ( gestureRecognizer == pvcPanGestureRecognizer &&
        [pvcPanGestureRecognizerDelegate respondsToSelector:@selector(gestureRecognizer:shouldReceiveTouch:)] )
    {
        return [pvcPanGestureRecognizerDelegate gestureRecognizer:gestureRecognizer
                                               shouldReceiveTouch:touch];
    }
    return YES;
}

- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
    shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
{
    if ( gestureRecognizer == pvcPanGestureRecognizer &&
        [pvcPanGestureRecognizerDelegate respondsToSelector:@selector(gestureRecognizer:shouldRecognizeSimultaneouslyWithGestureRecognizer:)] )
    {
        return [pvcPanGestureRecognizerDelegate gestureRecognizer:gestureRecognizer
               shouldRecognizeSimultaneouslyWithGestureRecognizer:otherGestureRecognizer];
    }
    return NO;
}

- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer
{
    if ( gestureRecognizer == pvcPanGestureRecognizer &&
        [pvcPanGestureRecognizerDelegate respondsToSelector:@selector(gestureRecognizerShouldBegin:)] )
    {
        return [pvcPanGestureRecognizerDelegate gestureRecognizerShouldBegin:gestureRecognizer];
    }
    return YES;
}

Apparently, the methods don't do anything sensible, they just forward the invocations to the original delegate (making sure that that actually implements them). Still, this forwarding seems to be sufficient for the PVC to behave and not call the datasource when there is no need to.

This workaround fixed the issue for me on devices running iOS 6. Code which was compiled with the iOS 6 SDK but with a deployment target of iOS 5 had already run flawlessly on 5.x devices, so the fix is not necessary there but according to my tests it doesn't do any harm either.

Hope someone finds this useful.

Community
  • 1
  • 1
Lvsti
  • 1,525
  • 15
  • 15
  • Be careful though, I have just detected some weird crashes which may be related to this workaround. Namely, if you have a finite number of pages and you try to **swipe** from the last page on, then the app crashes saying you didn't provide the necessary number of viewcontrollers, even if you intentionally returned `nil` to indicate there are no more. I'll post it here once I figure out the fix. – Lvsti Nov 12 '12 at 17:06
  • Lvsti, I am suffering from the same error. I know this was long ago but did you find a solution for this ? – Özgür Sep 04 '15 at 06:54
4

I have tried your solution and it came almost working, but still with some issues. The best solution came with adding method

 - (void)pageViewController:(UIPageViewController *)pageViewController willTransitionToViewControllers:(NSArray *)pendingViewControllers 

which is available starting from iOS 6 and it is required for it. If not to implement it, issues may occur with those gestures. Implementing it helped to solve major part of issues.

Sergii N.
  • 315
  • 3
  • 10
0

try this...

    - (UIViewController *)pageViewController:(UIPageViewController *)pageViewController viewControllerBeforeViewController:(UIViewController *)viewController
 {   

        for (UIGestureRecognizer *gr in pageViewController.gestureRecognizers) {
                if([gr isKindOfClass:[UIPanGestureRecognizer class]])
                {
                    UIPanGestureRecognizer *pgr = (UIPanGestureRecognizer*)gr;
                    CGPoint velocity = [pgr velocityInView:pageViewController.view];
                    BOOL verticalSwipe = fabs(velocity.y) > fabs(velocity.x);
                    if(verticalSwipe)
                        return nil;
                }
            }
    ....
}
PVCS
  • 3,831
  • 1
  • 16
  • 10