UIPageViewController and storyboard
Asked Answered
E

8

52

I'm trying to configure a UIPageViewController SPECIFICALLY from storyboard:

enter image description here

TutorialPageViewController.h

#import <UIKit/UIKit.h>
@interface TutorialPageViewController : UIPageViewController <UIPageViewControllerDelegate, UIPageViewControllerDataSource>
@end

TutorialPageViewController.m

#import "TutorialPageViewController.h"

@interface TutorialPageViewController ()
@property (assign, nonatomic) NSInteger index;
@end

@implementation TutorialPageViewController
{
    NSArray *myViewControllers;
}

- (id)initWithNibName:(NSString *)nibNameOrNil bundle:(NSBundle *)nibBundleOrNil
{
    self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil];
    if (self) {
        // Custom initialization
    }
    return self;
}

- (void)viewDidLoad
{
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    self.delegate = self;
    self.dataSource = self;
    [self didMoveToParentViewController:self];
    UIStoryboard *tutorialStoryboard = [UIStoryboard storyboardWithName:@"TutorialStoryboard" bundle:[NSBundle mainBundle]];
    UIViewController *tuto1 = [tutorialStoryboard instantiateViewControllerWithIdentifier:@"TutorialPageViewController_1"];
    UIViewController *tuto2 = [tutorialStoryboard instantiateViewControllerWithIdentifier:@"TutorialPageViewController_2"];

    myViewControllers = @[tuto1, tuto2, tuto1, tuto2];
    self.index = 0;

    [self setViewControllers:@[tuto1] direction:UIPageViewControllerNavigationDirectionForward animated:NO completion:nil];
}

- (UIViewController *)viewControllerAtIndex:(NSUInteger)index {
    return myViewControllers[index];
}

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

    NSUInteger index = self.index;

    if (index == 0) { return nil; }

    // Decrease the index by 1 to return
    index--;
    return [self viewControllerAtIndex:index];
}

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

    NSUInteger index = self.index;
    index++;
    if (index > [myViewControllers count]) { return nil; }

    return [self viewControllerAtIndex:index];
}

- (NSInteger)presentationCountForPageViewController:(UIPageViewController *)pageViewController {
    // The number of items reflected in the page indicator.
    return [myViewControllers count];
}

- (NSInteger)presentationIndexForPageViewController:(UIPageViewController *)pageViewController {
    // The selected item reflected in the page indicator.
    return 0;
}

@end

Problem is...

  • The first page displays well with the page indicator. While swiping,
  • I can see properly the second page.
  • As soon as the transition finishes, I get a black screen (with the page indicator properly displaying page number 2). No user interaction is available anymore.
Earthy answered 23/8, 2013 at 8:58 Comment(3)
This is the first time I've seen the UIPageViewController itself used for it's own datasource and delegate. Seems logical but every tutorial I've seen doesn't go this route. Hopefully you go tit working.Sulphate
@BenjaminToueg, I'm pretty sure "didMoveToParentViewController" is unnecessary here.Shayshaya
This older question is essentially easy, if you're comfortable with container views - tutorialShayshaya
S
80

2023. Swift updated.

Nowadays it is dead easy to do this simply using Storyboard.

These sort of "swiping full-screen tutorials" were popular as app "intros" for awhile, so I called the class below IntroPages.

Step 1, make a container view that is a UIPageViewController.

If new to iOS, here is a container view tutorial.

( Note: if you don't know how to "change" the container view to a UIPageViewController, scroll down to the section "How to change..." on that tutorial!

enter image description here )

You can make the container any shape you want. As with any container view, it can be full-screen or a small part of the screen - whatever you want.

Step 2,

Make four straightforward, ordinary, view controllers which can be anything you want - images, text, tables, anything at all. (Purple in the example.)

four controllers

Note that they simply sit there on your storyboard, do not link them to anything.

Step 3, you must Set the IDs of those four pages. "id1", "id2", "id3", "id4" is fine.

set the IDs

Step 4, copy and paste! Here's the class IntroPages,

Updated for 2023 ...

import UIKit
class IntroPages: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate {

    // (see note below about ".scroll" mode, you almost always need this line of code:)
   required init?(coder: NSCoder) {
       super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
   }

    var pages = [UIViewController]()

    override func viewDidLoad() {
        super.viewDidLoad()
        self.delegate = self
        self.dataSource = self

        let p1: UIViewController! = storyboard?.instantiateViewController(withIdentifier: "id1")
        let p2: UIViewController! = storyboard?.instantiateViewController(withIdentifier: "id2")
        let p3: UIViewController! = storyboard?.instantiateViewController(withIdentifier: "id3")

        // etc ...

        pages.append(p1)
        pages.append(p2)
        pages.append(p3)

        // etc ...

        setViewControllers([p1], direction: .forward, animated: false, completion: nil)
    }

    func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController)-> UIViewController? {
       
        guard let cur = pages.firstIndex(of: viewController) else { return nil }

        // if you prefer to NOT scroll circularly, simply add here:
        // if cur == 0 { return nil }

        var prev = (cur - 1) % pages.count
        if prev < 0 {
            prev = pages.count - 1
        }
        return pages[prev]
    }

    func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController)-> UIViewController? {

        guard let cur = pages.firstIndex(of: viewController) else { return nil }

        // if you prefer to NOT scroll circularly, simply add here:
        // if cur == (pages.count - 1) { return nil }

        let nxt = abs((cur + 1) % pages.count)
        return pages[nxt]
    }

    func presentationIndex(for pageViewController: UIPageViewController)-> Int {
        return pages.count
    }
}

(Look at the comments - there is code for either looping or linear paging as you prefer.)

On the storyboard look at the UIPageViewController. Set the class to be IntroPages.

That's all there is to it - you're done.

You simply set the transition style on the storyboard,

enter image description here

it is very likely you want "Scroll", not the other one.


Important NOTE on using the normal, usual, ".scroll" mode ...

Incredibly bizarrely, Apple have removed the normal ".scroll" option from the drop-down in IB in Xcode storyboard.

(On storyboard, they only allow you to choose the bizarre options like "curl", which nobody ever uses.)

You now have to do this in code, which is simple. This line takes care of the problem:

class IntroPages: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate {

   required init?(coder: NSCoder) {
       super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
   }

It's silly, but that's how it is. (More info.)


Surprisingly, you're done!

It's a bit confusing, because there's nothing else to do.

Hit run and it will now work.

You can go to lunch, there's nothing else to do.

Looking at the first large image above, the "pink" controllers "aa", "bb", "cc" etc ... simply make those any way you wish, as any normal view controller layout in iOS.

And now the hard part ...


Introduction to adding UIPageControl ...

You add the UIPageControl in the highest-level wrapper class, "Intro" in the above image example.

(So, surprisingly not in the page view controller, not in "IntroPages".)

Thus, on the storyboard, very simply drag a UIPageControl on to "Intro".

Note! Bizarrely, in storyboards, you cannot move a UIPageControl.

When you drag a page control on to a view controller, it just sits in a fixed place. This is completely bizarre but that's how it is. Don't waste time trying to move it :) If you want to reposition it against UI guidelines, just have an empty UIView where you wish, and sit the page control in side that holder view.

From Intro, you will need to access the page view controller (IntroPages) in the usual way:

class Intro: UIViewController {

    @IBOutlet var pageControl: UIPageControl!
    var pageViewController: IntroPages!
    
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        if segue.identifier == "segueIntroPages" {
            // if new to container views, identifier explained here:
            // https://mcmap.net/q/17273/-how-to-add-a-subview-that-has-its-own-uiviewcontroller-in-objective-c
            pageViewController = (segue.destination as! IntroPages)
        }
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.bringSubviewToFront(pageControl)
        pageControl.currentPage = 0
    }

Note this key line:

    view.bringSubviewToFront(pageControl)

Add this function

@IBAction func userDidChangePageControl(_ sender: UIPageControl) {}

and then in storyboard click on the page control and drag valueChanged to that function.

The simplest outline version of the function is ...

@IBAction func userDidChangePageControl(_ sender: UIPageControl) {
    let newIndex = sender.currentPage
    pageViewController.setViewControllers(
        [pageViewController.pages[newIndex]],
        direction: (pageViewController.currentIndex < newIndex)
                     ? .forward : .reverse,
        animated: true, completion: nil)
}

... and in IntroPages you have to add the property ...

var currentIndex: Int {
    if let visibleViewController = viewControllers?.first,
       let ci = pages.firstIndex(of: visibleViewController) {
        return ci
    }
    else {
        return 0
    }
}

... and in IntroPages also add the following, so that, when the user swipes the screen the UIPageControl knows what to do ...

func pageViewController(_ pageViewController: UIPageViewController, 
       didFinishAnimating finished: Bool,
       previousViewControllers: [UIViewController], 
       transitionCompleted completed: Bool) {
    (parent as? Intro)?.pageControl.currentPage = currentIndex
}

That will do it, but two points.

  1. See https://stackoverflow.com/questions/66545624 for a fuller investigation of the exquisite details of the Apple UI on the page controller.

  2. 2023? Traditionally in IntroPages you have to add the following due to an Apple bug / unusual behavior. It's unclear if this is still needed in the most recent iOS, noting that pad/phone seem to have quite different behaviors.

Traditionally had to add:

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    let subViews = view.subviews
    var scrollView: UIScrollView? = nil
    var pageControl: UIPageControl? = nil
    
    // maintain this code order...
    for view in subViews {
        if view.isKind(of: UIScrollView.self) {
            scrollView = view as? UIScrollView
        }
        else if view.isKind(of: UIPageControl.self) {
            pageControl = view as? UIPageControl
        }
    }
    
    // maintain this code order...
    if (scrollView != nil && pageControl != nil) {
        scrollView?.frame = view.bounds
        if let pageControl = pageControl {
                    view.bringSubviewToFront(pageControl) }
    }
} 
Shayshaya answered 24/9, 2014 at 19:19 Comment(8)
Nice -- this works fantastic! Now all it needs is the page indicators and a way to disable the page curl effect for a simple slide-in to the next view.Noma
This way we cannot programatically change the transitionStyle to Scroll From page curl. To solve that , drag a UIPageVIewController , connect it to the container (as embed) and in Attributed Inspector change transition style. Don't forget to change the class name.Capsulate
@JoeBlow Hi, I've followed your instruction. The IntroPages has show up but I can't swipe It !!. Am I missing something ??Telluric
Is it possible to handle "pages" array kind of implementation in storyboard itself? If I've 15 view controllers in the flow, instantiating all of them and storing in array might lead to memory issues and app might crash.....Cata
@Cata it's inconceivable you'll have a memory problem with 20 or 30 screens - just go ahead!Shayshaya
@Shayshaya would you know how to advance programatically? I think I need a notification/observer but I'm not sure how to go about it or how exactly to call the setViewControllers function correctly from an action in the parent VC. I wrote a question here if you have the time to take a look: #55199127 , I'd really appreciate it.Willams
@drew hope the years have been good to you :) For Drew or anyone reading I've added an essay on getting started w/ UIPageControl (the "dots at the bottom").Shayshaya
@Capsulate and others regarding USING SCROLL MODE, you just add one line of code - please see the answer (notice large headline about it).Shayshaya
A
22

For someone, who wants to see working page scroll (forward / backward)

-(UIViewController *)pageViewController:(UIPageViewController *)pageViewController
     viewControllerBeforeViewController:(UIViewController *)viewController
  {
     NSUInteger currentIndex = [myViewControllers indexOfObject:viewController];
     // get the index of the current view controller on display

     if (currentIndex > 0)
     {
        return [myViewControllers objectAtIndex:currentIndex-1];
        // return the previous viewcontroller
     } else
     {
         return nil;
         // do nothing
     }
  }
-(UIViewController *)pageViewController:(UIPageViewController *)pageViewController
 viewControllerAfterViewController:(UIViewController *)viewController
  {
     NSUInteger currentIndex = [myViewControllers indexOfObject:viewController];
     // get the index of the current view controller on display
     // check if we are at the end and decide if we need to present
     // the next viewcontroller
     if (currentIndex < [myViewControllers count]-1)
     {
        return [myViewControllers objectAtIndex:currentIndex+1];
        // return the next view controller
     } else
     {
        return nil;
        // do nothing
     }
  }

Just to add to this great answer by EditOR, here's what you do if you prefer "round and around" paging: still using the same technique of EditOR

-(UIViewController *)pageViewController:(UIPageViewController *)pageViewController
        viewControllerBeforeViewController:(UIViewController *)viewController
    {
    NSUInteger currentIndex = [myViewControllers indexOfObject:viewController];

    --currentIndex;
    currentIndex = currentIndex % (myViewControllers.count);
    return [myViewControllers objectAtIndex:currentIndex];
    }

-(UIViewController *)pageViewController:(UIPageViewController *)pageViewController
        viewControllerAfterViewController:(UIViewController *)viewController
    {
    NSUInteger currentIndex = [myViewControllers indexOfObject:viewController];

    ++currentIndex;
    currentIndex = currentIndex % (myViewControllers.count);
    return [myViewControllers objectAtIndex:currentIndex];
    } 
Arlyn answered 28/8, 2014 at 22:23 Comment(1)
Do we have any means to check the current page index number in a page view controller?Acclimatize
S
11

Extending Joe Blow's answer with Swift code for the UIPageViewController class:

import UIKit

class MyPageViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate {

    var pages = [UIViewController]()

    override func viewDidLoad() {
        super.viewDidLoad()

        self.delegate = self
        self.dataSource = self

        let page1: UIViewController! = storyboard?.instantiateViewControllerWithIdentifier("page1")
        let page2: UIViewController! = storyboard?.instantiateViewControllerWithIdentifier("page2")

        pages.append(page1)
        pages.append(page2)

        setViewControllers([page1], direction: UIPageViewControllerNavigationDirection.Forward, animated: false, completion: nil)
    }

    func pageViewController(pageViewController: UIPageViewController, viewControllerBeforeViewController viewController: UIViewController) -> UIViewController? {
        let currentIndex = pages.indexOf(viewController)!
        let previousIndex = abs((currentIndex - 1) % pages.count)
        return pages[previousIndex]
    }

    func pageViewController(pageViewController: UIPageViewController, viewControllerAfterViewController viewController: UIViewController) -> UIViewController? {
        let currentIndex = pages.indexOf(viewController)!
        let nextIndex = abs((currentIndex + 1) % pages.count)
        return pages[nextIndex]
    }

    func presentationCountForPageViewController(pageViewController: UIPageViewController) -> Int {
        return pages.count
    }

    func presentationIndexForPageViewController(pageViewController: UIPageViewController) -> Int {
        return 0
    }
}

Read more on using UIPageViewController with container view with storyboard setup.

Sashasashay answered 13/10, 2015 at 7:21 Comment(2)
This is handy, thanks. It should be noted that the above is for the "round and around" paging as mentioned in other answers (paging to the right will eventually start over at the beginning instead of hitting the end and stopping).Sauterne
outstanding, @SashasashayShayshaya
C
4

There seems to be a lot of questions regarding UIPageViewController in Storyboard.

Here is some demo code to show you how you can use the UIPageViewController in storyboard as a standalone full screen view or as a UIContainerView, if you want to page only a small area of your screen.

enter image description here enter image description here enter image description here

Contusion answered 25/9, 2015 at 3:55 Comment(0)
W
0

Updated for Swift 3:

class YourPageViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate {

    var pages = [UIViewController]()

    override func viewDidLoad() {
        super.viewDidLoad()

        self.delegate = self
        self.dataSource = self

        let page1: UIViewController! = storyboard?.instantiateViewController(withIdentifier: "page1")
        let page2: UIViewController! = storyboard?.instantiateViewController(withIdentifier: "page2")

        pages.append(page1)
        pages.append(page2)

        setViewControllers([page1], direction: UIPageViewControllerNavigationDirection.forward, animated: false, completion: nil)
    }

    func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
        let currentIndex = pages.index(of: viewController)!
        let previousIndex = abs((currentIndex - 1) % pages.count)
        return pages[previousIndex]
    }

    func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
        let currentIndex = pages.index(of: viewController)!
        let nextIndex = abs((currentIndex + 1) % pages.count)
        return pages[nextIndex]
    }

    func presentationIndex(for pageViewController: UIPageViewController) -> Int {
        return pages.count
    }
}
Whiggism answered 17/9, 2016 at 16:15 Comment(0)
B
0

To have infinite scroll back using @samwize's answer you need to add conditional to check for negative values. Otherwise you just switch between the first and second page. This is only necessary if you plan on having more than two pages.

func pageViewController(_ pageViewController: UIPageViewController,
                            viewControllerBefore viewController: UIViewController) -> UIViewController? {
        let currentIndex = pages.index(of: viewController)!
        var previousIndex = (currentIndex - 1) % pages.count
        if previousIndex < 0 {previousIndex = pages.count - 1}
        return pages[previousIndex]
    }
Bellinzona answered 6/1, 2017 at 18:15 Comment(0)
S
-1

The problem is that you're improperly reusing UIViewController instances:

myViewControllers = @[tuto1, tuto2, tuto1, tuto2];

I would suggest you to have an NSMutableSet that would serve as a pool of reusable UIViewController instances.

In viewControllerBeforeViewController: and viewControllerBeforeViewController: search your NSMutableSet using NSPredicate to find a UIViewController with parentViewController equal to nil. If you find one, return it. If not, instantiate a new one, add it to the NSMutableSet and then return it.

When you're done and your tests are passing, you can extract the pool into its own class.

Selfjustifying answered 18/1, 2014 at 23:30 Comment(2)
This does not seem to be correct, Rudolf. It works perfectly just with an ordinary array. No problem at all. All four VCs are just "there" - it's no problem.Shayshaya
@JoeBlow Yeah, array or set, it's up to you. The important thing is the algorithm, especially fact that UIPageViewController becomes the parentViewController. Based on that, you can filter the array/set and find an unused instance of UIViewController.Excavator
H
-2

Answer for Swift 5:

import UIKit

class MyPageViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate {

    var allViewControllers = [UIViewController]()

    override func viewDidLoad() {
        super.viewDidLoad()

        self.dataSource = self

        let vc1: UIViewController! = storyboard?.instantiateViewController(withIdentifier: "viewController1")
        let vc2: UIViewController! = storyboard?.instantiateViewController(withIdentifier: "viewController2")
        allViewControllers = [vc1, vc2]

        self.setViewControllers([vc1], direction: UIPageViewController.NavigationDirection.forward, animated: false)
    }

    // UIPageViewControllerDataSource

    func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController?
    {
        let currentIndex = allViewControllers.firstIndex(of: viewController)!
        return currentIndex == 0 ? nil : allViewControllers[currentIndex-1]
    }

    func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController?
    {
        let currentIndex = allViewControllers.firstIndex(of: viewController)!
        return currentIndex == allViewControllers.count-1 ? nil : allViewControllers[currentIndex+1]
    }

    func presentationCount(for pageViewController: UIPageViewController) -> Int
    {
      return allViewControllers.count
    }

    func presentationIndex(for pageViewController: UIPageViewController) -> Int
    {
        return 0
    }
}
Hebrews answered 3/9, 2019 at 20:13 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.