64

I've found a few questions about how to make a UIPageViewController jump to a specific page, but I've noticed an added problem with jumping that none of the answers seem to acknowledge.

Without going into the details of my iOS app (which is similar to a paged calendar), here is what I'm experiencing. I declare a UIPageViewController, set the current view controller, and implement a data source.

// end of the init method
        pageViewController = [[UIPageViewController alloc] 
        initWithTransitionStyle:UIPageViewControllerTransitionStyleScroll
          navigationOrientation:UIPageViewControllerNavigationOrientationHorizontal
                        options:nil];
        pageViewController.dataSource = self;
        [self jumpToDay:0];
}

//...

- (void)jumpToDay:(NSInteger)day {
        UIViewController *controller = [self dequeuePreviousDayViewControllerWithDaysBack:day];
        [pageViewController setViewControllers:@[controller]
                                    direction:UIPageViewControllerNavigationDirectionForward
                                     animated:YES
                                   completion:nil];
}

- (UIViewController *)pageViewController:(UIPageViewController *)pageViewController viewControllerAfterViewController:(UIViewController *)viewController {
        NSInteger days = ((THDayViewController *)viewController).daysAgo;
        return [self dequeuePreviousDayViewControllerWithDaysBack:days + 1];
}

- (UIViewController *)pageViewController:(UIPageViewController *)pageViewController viewControllerBeforeViewController:(UIViewController *)viewController {
        NSInteger days = ((THDayViewController *)viewController).daysAgo;
        return [self dequeuePreviousDayViewControllerWithDaysBack:days - 1];
}

- (UIViewController *)dequeuePreviousDayViewControllerWithDaysBack:(NSInteger)days {
        return [[THPreviousDayViewController alloc] initWithDaysAgo:days];
}

Edit Note: I added simplified code for the dequeuing method. Even with this blasphemous implementation I have the exact same problem with page order.

The initialization all works as expected. The incremental paging all works fine as well. The issue is that if I ever call jumpToDay again, the order gets jumbled.

If the user is on day -5 and jumps to day 1, a scroll to the left will reveal day -5 again instead of the appropriate day 0. This seems to have something to do with how UIPageViewController keeps references to nearby pages, but I can't find any reference to a method that would force it to refresh it's cache.

Any ideas?

KlimczakM
  • 12,576
  • 11
  • 64
  • 83
Kyle
  • 1,434
  • 1
  • 11
  • 14
  • I don't think the problem has to do with the way a page controller keeps references to nearby pages -- I don't think it even does that. It's your responsibility, in the implementation of your data source, to keep track of the pages. So, something is probably wrong with daysAgo or with the dequeuePreviousDayViewControllerWithDaysBack: method. – rdelmar Nov 29 '12 at 20:11
  • did you solve this issue ? Im having the same problem ? – Seb Kade Nov 29 '12 at 23:16
  • 1
    @rdelmar Normally I'd concede that this is my fault, but the "dequeue" method literally just instantiates a controller of the same type with an internal reference to the given "days back". It doesn't even do any of the fancy queueing and allocing that a UITableView takes care of in its dequeueing method. – Kyle Nov 30 '12 at 16:06
  • @SebKade Haven't found a solution, but since this needs to get done today, I'm considering rolling my own pageviewcontroller class. I'll let you know if it works out. – Kyle Nov 30 '12 at 16:08
  • I'd join rdelmar's opinion. I implemented a UIPageViewController with approximately the same properties, and I never had any problem. What do you get as days in viewControllerBeforeViewController ? – Snaker Nov 30 '12 at 16:24
  • Well, then there has to be something wrong with daysAgo since that's the only thing determining which controller gets instantiated. As for rolling your own, if you're doing a single page at a time, then it doesn't seem to me that the page view controller does all that much for you. I've done one I called a ring controller that allows you to go backwards and forwards a page, and also jump to any page. It wasn't hard to implement but my current implementation instantiates all the page controllers up front which isn't very memory efficient. – rdelmar Nov 30 '12 at 17:23
  • @rdelmar daysAgo is literally just an accessor to the NSInteger passed in the init method. I agree with you that a UIPageViewController is probably not what I'm looking for in this situation given some of the other limitations I'm seeing (e.g. ability to observe the content offset of pages). For your ring controller, are you just throwing all the pages into a UIScrollView? I've seen some apps do something similar but wasn't sure how they accomplished it. – Kyle Nov 30 '12 at 17:54
  • This question/answer solves problem much easier. http://stackoverflow.com/questions/22554877/changing-uipageviewcontrollers-page-programmatically-doesnt-update-the-uipagec – kraag22 Dec 09 '14 at 08:04

13 Answers13

84

Programming iOS6, by Matt Neuburg documents this exact problem, and I actually found that his solution feels a little better than the currently accepted answer. That solution, which works great, has a negative side effect of animating to the image before/after, and then jarringly replacing that page with the desired page. I felt like that was a weird user experience, and Matt's solution takes care of that.

__weak UIPageViewController* pvcw = pvc;
[pvc setViewControllers:@[page]
              direction:UIPageViewControllerNavigationDirectionForward
               animated:YES completion:^(BOOL finished) {
                   UIPageViewController* pvcs = pvcw;
                   if (!pvcs) return;
                   dispatch_async(dispatch_get_main_queue(), ^{
                       [pvcs setViewControllers:@[page]
                                  direction:UIPageViewControllerNavigationDirectionForward
                                   animated:NO completion:nil];
                   });
               }];
djibouti33
  • 12,102
  • 9
  • 83
  • 116
  • Thank! That's right answer! @Johannes, according your question - check this: http://stackoverflow.com/a/20032131/1698467 – skywinder May 20 '14 at 07:55
  • I don't understand how to use this solution, where do I have to use this code? i need to jump with a button at the last page. – Alessio Crestani Jun 24 '14 at 09:28
  • You'd probably want to put this code in your button's action method. You'll need to store a reference to your PVC, so in the first line of the code sample, pvcw should be set to self.pvc. – djibouti33 Jun 24 '14 at 14:32
  • this is unnecessary now, http://stackoverflow.com/questions/22554877/changing-uipageviewcontrollers-page-programmatically-doesnt-update-the-uipagec – kraag22 Dec 09 '14 at 08:04
  • 1
    @kraag22, I'd love to see how it's unnecessary now. The link you shared seems unrelated though – Ali Saeed Feb 15 '15 at 13:50
  • This is very excellent solution and tt's even better because the specific page will be shifted to the first page and user will always start from the first dot in the Page Control – Patrik Vaberer Mar 30 '15 at 16:28
  • 3
    Can anyone provide a swift version of this? Please. – iYoung Jul 14 '15 at 06:57
37

So I ran into the same problem as you where I needed to be able to 'jump' to a page and then found the 'order messed up' when I gestured back a page. As far as I have been able to tell, the page view controller is definitely caching the view controllers and when you 'jump' to a page you have to specify the direction: forward or reverse. It then assumes that the new view controller is a 'neighbor' to the previous view controller and hence automagically presents the previous view controller when you gesture back. I found that this only happens when you are using the UIPageViewControllerTransitionStyleScroll and not UIPageViewControllerTransitionStylePageCurl. The page curl style apparently does not do the same caching since if you 'jump' to a page and then gesture back it delivers the pageViewController:viewController(Before/After)ViewController: message to the data source enabling you to provide the correct neighbor view controller.

Solution: When performing a 'jump' to page you can first jump to the neighbor page to the page (animated:NO) you are jumping to and then in the completion block of that jump, jump to the desired page. This will update the cache such that when you gesture back, the correct neighbor page will be displayed. The downside is that you will need to create two view controllers; the one you are jumping to and the one that should be displayed after gesturing back.

Here is the code to a category that I wrote for UIPageViewController:

@implementation UIPageViewController (Additions)

 - (void)setViewControllers:(NSArray *)viewControllers direction:(UIPageViewControllerNavigationDirection)direction invalidateCache:(BOOL)invalidateCache animated:(BOOL)animated completion:(void (^)(BOOL finished))completion {
    NSArray *vcs = viewControllers;
    __weak UIPageViewController *mySelf = self;

    if (invalidateCache && self.transitionStyle == UIPageViewControllerTransitionStyleScroll) {
        UIViewController *neighborViewController = (direction == UIPageViewControllerNavigationDirectionForward
                                                    ? [self.dataSource pageViewController:self viewControllerBeforeViewController:viewControllers[0]]
                                                    : [self.dataSource pageViewController:self viewControllerAfterViewController:viewControllers[0]]);
        [self setViewControllers:@[neighborViewController] direction:direction animated:NO completion:^(BOOL finished) {
            [mySelf setViewControllers:vcs direction:direction animated:animated completion:completion];
        }];
    }
    else {
        [mySelf setViewControllers:vcs direction:direction animated:animated completion:completion];
    }
}

@end

What you can do to test this is create a new 'Page-Based Application' and add a 'goto' button that will 'jump' to a certain calendar month and then gesture back. Be sure to set the transition style to scroll.

KlimczakM
  • 12,576
  • 11
  • 64
  • 83
Spencer Hall
  • 3,739
  • 1
  • 18
  • 8
  • 2
    btw, When I set animated to NO in setViewControllers:direction:animated:completion its not messing up the order..! I guess this "caching" happens only in cases when we animate the setViewControllers. – akshaynhegde Aug 12 '13 at 08:36
  • hi, i found your code useful but i'm wondering if you have version in Swift because I having difficulty in weakSelf. – shoujo_sm Jun 18 '15 at 16:39
  • @Spencer Hall you should make some example project of this and host in github its really helps people :) – Gurumoorthy Arumugam Jul 07 '16 at 10:15
  • This is no longer needed for iOS 10. You can just use the normal method – user1416564 Nov 02 '16 at 01:39
  • I changed it to viewControllers.firstObject and viewControllers.lastObject rather than viewControllers[0] to handle more than 1 VCs in the array. I think that should allow to remove the transitionStyle check and be more generic. My use case only deals with scroll style so haven't been able to test. Please do comment if any one can test it. Thanks – Numan Tariq Jun 01 '17 at 14:09
5

I use this function (I'm always in landscape, 2 page mode)

-(void) flipToPage:(NSString * )index {


int x = [index intValue];
LeafletPageContentViewController *theCurrentViewController = [self.pageViewController.viewControllers   objectAtIndex:0];

NSUInteger retreivedIndex = [self indexOfViewController:theCurrentViewController];

LeafletPageContentViewController *firstViewController = [self viewControllerAtIndex:x];
LeafletPageContentViewController *secondViewController = [self viewControllerAtIndex:x+1 ];


NSArray *viewControllers = nil;

viewControllers = [NSArray arrayWithObjects:firstViewController, secondViewController, nil];


if (retreivedIndex < x){

    [self.pageViewController setViewControllers:viewControllers direction:UIPageViewControllerNavigationDirectionForward animated:YES completion:NULL];

} else {

    if (retreivedIndex > x ){

        [self.pageViewController setViewControllers:viewControllers direction:UIPageViewControllerNavigationDirectionReverse animated:YES completion:NULL];
      } 
    }
} 
jcesarmobile
  • 51,328
  • 11
  • 132
  • 176
5

Here is my Swift solution to be used for subclasses of UIPageViewController:

Assume you store an array of viewControllers in viewControllerArray and the current page index in updateCurrentPageIndex.

  private func slideToPage(index: Int, completion: (() -> Void)?) {
    let tempIndex = currentPageIndex
    if currentPageIndex < index {
      for var i = tempIndex+1; i <= index; i++ {
        self.setViewControllers([viewControllerArray[i]], direction: UIPageViewControllerNavigationDirection.Forward, animated: true, completion: {[weak self] (complete: Bool) -> Void in
          if (complete) {
            self?.updateCurrentPageIndex(i-1)
            completion?()
          }
          })
      }
    }
    else if currentPageIndex > index {
      for var i = tempIndex - 1; i >= index; i-- {
        self.setViewControllers([viewControllerArray[i]], direction: UIPageViewControllerNavigationDirection.Reverse, animated: true, completion: {[weak self] (complete: Bool) -> Void in
          if complete {
            self?.updateCurrentPageIndex(i+1)
            completion?()
          }
          })
      }
    }
  }
Michael
  • 32,527
  • 49
  • 210
  • 370
  • Doesn't this force all the controllers between the current and the target to be loaded in memory? – Pochi Dec 03 '16 at 07:18
3

Swift version of djibouti33's answer:

weak var pvcw = pageViewController
pageViewController!.setViewControllers([page], direction: UIPageViewControllerNavigationDirection.Forward, animated: true) { _ in
        if let pvcs = pvcw {
            dispatch_async(dispatch_get_main_queue(), {
                pvcs.setViewControllers([page], direction: UIPageViewControllerNavigationDirection.Forward, animated: false, completion: nil)
            })
        }
    }
julian
  • 111
  • 10
  • it seems that after the transition, my page indexes are shifted by 1. What am I missing? – zevij Feb 04 '16 at 15:10
3

It's important to note that this is no longer the case in iOS 10 and you no longer have to use the accepted answer solution. Just continue as always.

user1416564
  • 401
  • 5
  • 18
  • @Chiquis I meant, that if you are using iOS 10, you need to remove this method otherwise you get issues. So you'll have to have two different ways, this way, and then without this way. – user1416564 Dec 05 '16 at 02:33
2

I can confirm this issue, and that it only happens when using UIPageViewControllerTransitionStyleScroll and not UIPageViewControllerTransitionStylePageCurl.

Workaround: Make a loop and call UIPageViewController setViewControllers for each page turn, until you reach the desired page.

This keeps the internal datasource index in UIPageViewController in sync.

Peter Boné
  • 131
  • 7
2

This is only solution

-(void)buyAction
{
    isFromBuy = YES;
    APPChildViewController *initialViewController = [self viewControllerAtIndex:4];
    viewControllers = [NSArray arrayWithObject:initialViewController];
    [self.pageController setViewControllers:viewControllers direction:UIPageViewControllerNavigationDirectionForward animated:NO completion:nil];
}

-(NSInteger)presentationIndexForPageViewController:(UIPageViewController *)pageViewController 
{
    if (isFromBuy) {
        isFromBuy = NO;
        return 5;
    }
    return 0;
}
Pang
  • 9,564
  • 146
  • 81
  • 122
Jagveer Singh
  • 2,258
  • 19
  • 34
1

I had a different approach, should be possible if your pages are designed to be updated after init:
When a manual page is selected I update a flag

- (void)scrollToPage:(NSInteger)page animated:(BOOL)animated
{
    if (page != self.currentPage) {
        [self setViewControllers:@[[self viewControllerForPage:page]]
                       direction:(page > self.currentPage ?
                                  UIPageViewControllerNavigationDirectionForward :
                                  UIPageViewControllerNavigationDirectionReverse)
                        animated:animated
                      completion:nil];
        self.currentPage = page;
        self.forceReloadNextPage = YES; // to override view controller automatic page cache
    }
}

- (ScheduleViewController *)viewControllerForPage:(NSInteger)page
{
    CustomViewController * scheduleViewController = [self.storyboard instantiateViewControllerWithIdentifier:@"CustomViewController"];
    scheduleViewController.view.tag = page; // keep track of pages using view.tag property
    scheduleViewController.data = [self dataForPage:page];

    if (self.currentViewController)
        scheduleViewController.calendarLayoutHourHeight = self.currentViewController.calendarLayoutHourHeight;

    return scheduleViewController;
}

and then force the the next page to reload with the correct data:

- (void)pageViewController:(UIPageViewController *)pageViewController willTransitionToViewControllers:(NSArray *)pendingViewControllers
{
    CustomViewController * nextViewController = [pendingViewControllers lastObject];

    // When manual scrolling occurs, the next page is loaded from UIPageViewController cache
    //  and must be refreshed
    if (self.forceReloadNextPage) {
        // calculate the direction of the scroll to know if to load next or previous page
        NSUInteger page = self.currentPage + 1;
        if (self.currentPage > nextViewController.view.tag) page = self.currentPage - 1;

        nextViewController.data = [self dataForPage:page];
        self.forceReloadNextPage = NO;
    }
}
Yariv Nissim
  • 13,273
  • 1
  • 38
  • 44
1

If you do not need to animate to the new page, as I didn't, the following code worked for me, called on "Value Changed" in the storyboard. Instead of changing between view controllers, I change the data associated with the current view controller.

    - (IBAction)pageControlCurrentPageDidChange:(id)sender
{
    self.currentIndex = self.pageControl.currentPage;
    MYViewController *currentPageViewController = (MYViewController *)self.pageViewController.viewControllers.firstObject;
    currentPageViewController.pageData = [self.pageDataSource dataForPage:self.currentIndex];
    [currentPageViewController updateDisplay];
}

currentIndex is there so I can update the pageControl's currentPage when I swipe between pages.

pageDataSource dataForPage: returns an array of data objects that are displayed by the pages.

Erich Wood
  • 361
  • 3
  • 3
  • Can you shave implementation of MYViewController class, because I can't see any iOS ViewController class that has pageData property and updateDisplay method. – Josip B. Mar 26 '14 at 12:51
  • pageData is a property in MYViewController that can be anything to hold the data for the view controller, like a dictionary or core data managed object. updateDisplay is just a method that displays the content for the page data. These are custom to MYViewController, an example view controller name of course. – Erich Wood May 27 '14 at 02:54
1

Here is an up-to-date Swift 3+ version of the answer by @djibouti33 with cleaned-up syntax.

weak var weakPageVc = pageVc

pageVc.setViewControllers([page], direction: .forward, animated: true) { finished in
    guard let pageVc = weakPageVc else {
        return
    }

    DispatchQueue.main.async {
        pageVc.setViewControllers([page], direction: .forward, animated: false)
    }
}
Tamás Sengel
  • 55,884
  • 29
  • 169
  • 223
1
    let orderedViewControllers = [UIViewController(),UIViewController(), UIViewController()]
    let pageViewController = UIPageViewController()
    let pageControl = UIPageControl()

    func jump(to: Int, completion: @escaping (_ vc: UIViewController?) -> Void){

        guard orderedViewControllers.count > to else{
            //index of bounds
            return
        }

        let toVC = orderedViewControllers[to]

        var direction: UIPageViewController.NavigationDirection = .forward

        if pageControl.currentPage < to {
            direction = .forward;
        } else {
            direction = .reverse;
        }

        pageViewController.setViewControllers([toVC], direction: direction, animated: true) { _ in
            DispatchQueue.main.async {
                self.pageViewController.setViewControllers([toVC], direction: direction, animated: false){ _ in
                    self.pageControl.currentPage = to
                        completion(toVC)

                }
            }
        }
    }

USAGE:

self.jump(to: 5) { (vc) in
    // you can do anything for new vc.
}
Okan
  • 410
  • 4
  • 10
0

I was struggling with this issue for a long time myself. For me I had a UIPageViewController (I called it PageController) load from storyboard and on it I add a UIViewController 'ContentVC'.

I let the ContentVC takes care of the data to be loaded on to the content area and let PageController takes care of the sliding/goto/PageIndicator updates. The ContentVC has an ivar CurrentPageIndex and sends that value to PageController so PageController knows which page it's on. In my .m file that has PageController I have these two methods.

Note that I used set to 0 and so every time PageVC reloads it goes to the first page which I don't want, [self viewControllerAtIndex:0].

- (void)setPageForward
{  
  ContentVC *FirstVC = [self viewControllerAtIndex:[CurrentPageIndex integerValue]];

  NSArray *viewControllers = @[FirstVC];
  [PageController setViewControllers:viewControllers direction:UIPageViewControllerNavigationDirectionForward animated:NO completion:nil];
}

This second method is PageViewController's DataSource method. presentationIndexForPageViewController will set the highlighted dot to the right page (the page you want). Note that if we return 0 here the page indicator will highlight the first dot which indicates the first page and we don't want that.

- (NSInteger)presentationIndexForPageViewController:(UIPageViewController *)pageViewController 
{
  return [CurrentPageIndex integerValue];
}
Ohmy
  • 2,201
  • 21
  • 24