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.
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:
pageViewController:viewControllerBeforeViewController:
and pageViewController:viewControllerAfterViewController:
methods can be called an arbitrary number of times by UIKit, andpageViewController: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:
the user begins fiddling with the page: A good indicator for this is the before/after callbacks, or more precisely the first of them.
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.
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".
UIKit starts the paging animation: I have no idea how to get a direct feedback for this.
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!