wannabegeek

Icon

Things I find interesting – generally programming

Using UIPageControl as a container UIViewController

Using a UIPageControl to navigate though a series of UIView (or UIViewControllers) I would imagine is a fairly common task. However, there is now Apple provided way of doing this with out writing some code.

In the latest iOS release (5.0), Apple have provided methods for implementing container UIViewController’s (documentation here), although they don’t help a great deal in this case.

Quite a bit of this was taken from this Cocoa with Love post

We are going to end up with something like this…

Setting up the interface

To setup the user interface in Interface Builder, you’ll need to create a UIPageControl and a UIScrollView within a UIViewController, and as many other UIViewControllers as to need.

Setting up the PageViewController class

The PageViewController will contain 2 subview a UIScrollView and a UIPageControl. The UIScrollView will contain the views while the UIPageControl will navigate and control which part of the UIScrollView is visible.
You’ll also need some mechanism for calling addChildViewController: on the view controller, I’m my example I did this by sub-classing PagerViewController, but you could also do this before the view controller is push into view.

So, the public interface should look like this:

@interface PagerViewController : UIViewController 
 
@property (nonatomic, strong) IBOutlet UIScrollView *scrollView;
@property (nonatomic, strong) IBOutlet UIPageControl *pageControl;
 
- (IBAction)changePage:(id)sender;
 
@end

On to the implementation file. First of all, I want to handle all the view appearence at rotation calls myself so, we need to over-ride automaticallyForwardAppearanceAndRotationMethodsToChildViewControllers and return NO indicating we don’t want the UIViewController super class doing this.
When the PagerViewController view comes into view, we will need to signal to the currently active child view controller that it has become visible. So we will forward on the view appeared/disappeared messages.

- (BOOL)automaticallyForwardAppearanceAndRotationMethodsToChildViewControllers {
	return NO;
}
 
- (void)viewDidAppear:(BOOL)animated {
	[super viewDidAppear:animated];
	UIViewController *viewController = [self.childViewControllers objectAtIndex:self.pageControl.currentPage];
	if (viewController.view.superview != nil) {
		[viewController viewDidAppear:animated];
	}
}
 
- (void)viewWillDisappear:(BOOL)animated {
	UIViewController *viewController = [self.childViewControllers objectAtIndex:self.pageControl.currentPage];
	if (viewController.view.superview != nil) {
		[viewController viewWillDisappear:animated];
	}
	[super viewWillDisappear:animated];
}
 
- (void)viewDidDisappear:(BOOL)animated {
	UIViewController *viewController = [self.childViewControllers objectAtIndex:self.pageControl.currentPage];
	if (viewController.view.superview != nil) {
		[viewController viewDidDisappear:animated];
	}
	[super viewDidDisappear:animated];
}

viewWillAppear is a little more complex since we also want to load all the child view controllers into the scroll view (we’ll cover loadScrollViewWithPage next), and also make sure the scroll view’s content size is large enough to handle all the child views.

- (void)viewWillAppear:(BOOL)animated {
	[super viewWillAppear:animated];
 
	for (NSUInteger i =0; i < [self.childViewControllers count]; i++) {
		[self loadScrollViewWithPage:i];
	}
 
	self.pageControl.currentPage = 0;
	_page = 0;
	[self.pageControl setNumberOfPages:[self.childViewControllers count]];
 
	UIViewController *viewController = [self.childViewControllers objectAtIndex:self.pageControl.currentPage];
	if (viewController.view.superview != nil) {
		[viewController viewWillAppear:animated];
	}
 
	self.scrollView.contentSize = CGSizeMake(scrollView.frame.size.width * [self.childViewControllers count], scrollView.frame.size.height);
}

To load the content of the UIViewController into the UIScrolView contentView we go though each child and add its view as a subview of the UIScrollView off-setting the origin by a screen width each time.

- (void)loadScrollViewWithPage:(int)page {
    if (page < 0)
        return;
    if (page >= [self.childViewControllers count])
        return;
 
	// replace the placeholder if necessary
    UIViewController *controller = [self.childViewControllers objectAtIndex:page];
    if (controller == nil) {
		return;
    }
 
	// add the controller's view to the scroll view
    if (controller.view.superview == nil) {
        CGRect frame = self.scrollView.frame;
        frame.origin.x = frame.size.width * page;
        frame.origin.y = 0;
        controller.view.frame = frame;
        [self.scrollView addSubview:controller.view];
    }
}

Handling scrolling

To handle scrolling we need to implement a few UIScrollViewDelegate methods and also the - (IBAction)changePage:(id)sender method we declared earlier.
We need to know how the scrolling occurred, i.e. was it initiated from a gesture swipe across the screen, or by tapping either side of the UIPageControl.

To work this out we update an ivar when the various delegate callback are called..
This code is largely taken from this Cocoa with Love post
I also call the child UIViewController’s viewillAppear etc. methods.

// At the begin of scroll dragging, reset the boolean used when scrolls originate from the UIPageControl
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView {
         _pageControlUsed = NO;
}
 
// At the end of scroll animation, reset the boolean used when scrolls originate from the UIPageControl
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
         _pageControlUsed = NO;
}
 
- (void)scrollViewDidEndScrollingAnimation:(UIScrollView *)scrollView {
        UIViewController *oldViewController = [self.childViewControllers objectAtIndex:_page];
        UIViewController *newViewController = [self.childViewControllers objectAtIndex:self.pageControl.currentPage];
        [oldViewController viewDidDisappear:YES];
        [newViewController viewDidAppear:YES];
 
        _page = self.pageControl.currentPage;
}

Now, to update the display after the page change we just need implement the scrollViewDidScroll delegate method and the changePage IBAction method.
changing the viewable UIViewController is done by simply scrolling the UIScrollView to the appropriate location.

- (IBAction)changePage:(id)sender {
        int page = ((UIPageControl *)sender).currentPage;
 
        // update the scroll view to the appropriate page
        CGRect frame = self.scrollView.frame;
        frame.origin.x = frame.size.width * page;
        frame.origin.y = 0;
 
        UIViewController *oldViewController = [self.childViewControllers objectAtIndex:_page];
        UIViewController *newViewController = [self.childViewControllers objectAtIndex:self.pageControl.currentPage];
        [oldViewController viewWillDisappear:YES];
        [newViewController viewWillAppear:YES];
 
        [self.scrollView scrollRectToVisible:frame animated:YES];
 
        // Set the boolean used when scrolls originate from the UIPageControl. See scrollViewDidScroll: above.
        _pageControlUsed = YES;
}
 
- (void)scrollViewDidScroll:(UIScrollView *)sender {
    // We don't want a "feedback loop" between the UIPageControl and the scroll delegate in
    // which a scroll event generated from the user hitting the page control triggers updates from
    // the delegate method. We use a boolean to disable the delegate logic when the page control is used.
    if (_pageControlUsed || _rotating) {
        // do nothing - the scroll was initiated from the page control, not the user dragging
        return;
    }
 
    // Switch the indicator when more than 50% of the previous/next page is visible
        CGFloat pageWidth = self.scrollView.frame.size.width;
        int page = floor((self.scrollView.contentOffset.x - pageWidth / 2) / pageWidth) + 1;
        if (self.pageControl.currentPage != page) {
                UIViewController *oldViewController = [self.childViewControllers objectAtIndex:self.pageControl.currentPage];
                UIViewController *newViewController = [self.childViewControllers objectAtIndex:page];
                [oldViewController viewWillDisappear:YES];
                [newViewController viewWillAppear:YES];
                self.pageControl.currentPage = page;
                [oldViewController viewDidDisappear:YES];
                [newViewController viewDidAppear:YES];
                _page = page;
        }
}

Finally, Rotation

To handle device rotation, we need to return YES from shouldAutorotateToInterfaceOrientation.
We also need to pass on the following messages to the currently active child UIViewController.

  • willAnimateRotationToInterfaceOrientation:duration:
  • willRotateToInterfaceOrientation:duration:
  • didRotateFromInterfaceOrientation:

But, we also need to handle our own rotation, i.e. resizing the scrollviews contentView, and adjusting the frame of the child subviews, otherwise everything is miss-aligned. This is done in willAnimateRotationToInterfaceOrientation:duration:, so that the resizing is also animated.

When the frame of UIScrollView adjusts scrollViewDidScroll: also gets called, so to prevent that from flipping us to a different page we set and unset the _rotating flag in the following methods:

  • willRotateToInterfaceOrientation:duration:
  • didRotateFromInterfaceOrientation:
- (BOOL)shouldAutorotateToInterfaceOrientation:(UIInterfaceOrientation)interfaceOrientation
{
	return YES;
}
 
- (void)willRotateToInterfaceOrientation:(UIInterfaceOrientation)toInterfaceOrientation duration:(NSTimeInterval)duration {
	UIViewController *viewController = [self.childViewControllers objectAtIndex:self.pageControl.currentPage];
	[viewController willRotateToInterfaceOrientation:toInterfaceOrientation duration:duration];
	_rotating = YES;
}
 
- (void)willAnimateRotationToInterfaceOrientation:(UIInterfaceOrientation)toInterfaceOrientation duration:(NSTimeInterval)duration {
 
	UIViewController *viewController = [self.childViewControllers objectAtIndex:self.pageControl.currentPage];
	[viewController willAnimateRotationToInterfaceOrientation:toInterfaceOrientation duration:duration];
 
	self.scrollView.contentSize = CGSizeMake(scrollView.frame.size.width * [self.childViewControllers count], scrollView.frame.size.height);
	NSUInteger page = 0;
	for (viewController in self.childViewControllers) {
		CGRect frame = self.scrollView.frame;
		frame.origin.x = frame.size.width * page;
		frame.origin.y = 0;
		viewController.view.frame = frame;
		page++;
	}
 
	CGRect frame = self.scrollView.frame;
	frame.origin.x = frame.size.width * _page;
	frame.origin.y = 0;
	[self.scrollView scrollRectToVisible:frame animated:NO];
 
}
 
- (void)didRotateFromInterfaceOrientation:(UIInterfaceOrientation)fromInterfaceOrientation {
	_rotating = NO;
	UIViewController *viewController = [self.childViewControllers objectAtIndex:self.pageControl.currentPage];
	[viewController didRotateFromInterfaceOrientation:fromInterfaceOrientation];
}

Conclusion

For fully working example project see this repository on GitHub

Category: Cocoa, iOS, iOS5, iPad, iPhone, Programming, UIKit

Tagged:

76 Responses

  1. Filip says:

    Hi again Tom,

    you’ve understood my question correctly! I also believe that the UIPageControl is hidden behind the ViewControllers because the PageControl works fine it is just the ‘dots’ that are not showing.
    Unfortunately
    [self.view bringSubviewToFront:self.pageControl];

    did not do the trick. You don’t happen to have any other aces up your sleve? :)

    Regards,
    Filip

  2. Filip says:

    Hi again Tom,

    sorry for the spamming. I just wanted to share my solution for this. I call

    [scrollView bringSubviewToFront:self.pageControl];

    in viewWillAppear and then I added

    // Change the position of the pageControl dots to match the view
    CGRect frame = pageControl.frame;
    frame.origin.x = sender.contentOffset.x;
    pageControl.frame = frame;

    to the ‘scrollViewDidScroll:(UIScrollView *)sender’ – method. Works like a charm it does! :)

    Also found a minor bug in that method which will lead to a crash if the user scrolls to much to the left or the right of the scroll view (more than 50% to the left or right to be specific). By adding the following to the ‘if’ in ‘scrollViewDidScroll:(UIScrollView *)sender’ – method we wont go outside of the bounds in the array of ViewControllers.

    if (self.pageControl.currentPage != page && page>=0 && page<self.childViewControllers.count) {
    //…
    }

    Thanks again for a great tutorial!!

    Regards,
    Filip

  3. magno says:

    Hi,

    iOS 6 introduced two new appearance and rotation forwarding methods on UIViewController and deprecated automaticallyForwardAppearanceAndRotationMethodsToChildViewControllers. The two new methods that replace it are:

    shouldAutomaticallyForwardAppearanceMethods and
    shouldAutomaticallyForwardRotationMethods

    can you update this tutorial, so it works with ios6.

    Thanks for your time, Greetings

  4. Mattow says:

    Hi,

    thank you so much for the tutorial. It helped a lot

    Now I found out, that the orientation is not working properly since some changes have been make in IOS 6.
    Could you give some advice on how to get it working on IOS6 devices, please.

    Regards,
    Mattow

  5. Caleb says:

    I was able to get this working with auto layout by moving everything from viewWillAppear to viewDidAppear.

    I can’t guarantee that there are no other repercussions to doing this but it seems to be working so far.

  6. Egon van Os says:

    Hi Tom,

    First of all thanks for the excellent tutorial. I have implemented the page control in my app.

    During testing of my app I noticed following:

    If i am at the first page and swipe to right beyond 50% of the pageWidth, the app terminates with following report

    *** Terminating app due to uncaught exception ‘NSRangeException’, reason: ‘*** -[__NSArrayI objectAtIndex:]: index 4294967295 beyond bounds [0 .. 1]‘>>

    If i am at the last page and swipe to left beyond 50% of the pageWidth, the app terminates with following report

    *** Terminating app due to uncaught exception ‘NSRangeException’, reason: ‘*** -[__NSArrayI objectAtIndex:]: index 2 beyond bounds [0 .. 1]‘

    To avoid this behavior I updated the scrollViewDidScroll method as indicated below:

    .
    .
    .
    int page = floor((self.scrollView.contentOffset.x – pageWidth / 2) / pageWidth) + 1;

    // added to aviod uncaught NSRangeException exception if int page is beyond bounds

    if (page pageControl.numberOfPages – 1)
    {
    page = pageControl.numberOfPages – 1;
    }
    }

    Although this solved the problem you might have a better solution.

    Regards,

    Egon

  7. voyage11 says:

    Hi Tom,
    It is a nice tutorial. I only have 3 months of experience in Cocoa. I am trying to learn the page control UI element and found this tutorial very useful. I am actually trying to use your code on iPad, I found a bug. When the iPad is starting from horizontal position, the interface will mess up. But after 1 rotation, it will be fixed. I have been trying to fix the bug myself for over 2 days but not able to do that. Not sure if you are able to help me with this.

    I just posted my question on stackoverflow: http://stackoverflow.com/questions/14432345/ipad-horizontal-orientation-bug-and-uiimageview-animation

    together with another question related to manipulate the animation of the imageview of a childviewcontroller.

  8. voyage11 says:

    Tom, I fixed the bug. For those people who have the landscape orientation issue fir iOS 6.0. Just paste the following code:

    -(void)viewWillLayoutSubviews{
    [self willAnimateRotationToInterfaceOrientation:self.interfaceOrientation duration:0.0];
    }

    on CustomPagerViewController.m then it is fixed.

  9. voyage11 says:

    Tom, you can reject my previous message. I think I pasted the wrong code.

    To anyone who has problem with iOS 6.0 landscape. just paste this line of code

    -(void)viewWillLayoutSubviews{
    [self willAnimateRotationToInterfaceOrientation:self.interfaceOrientation duration:0.0];
    }

    in CustomPagerViewController.m then the bug should be fixed.

  10. [...] Posted on 2013/01/22 by admin 英文原版:http://www.wannabegeek.com/?p=168 [...]

  11. Andrew says:

    When i turn autolayout on the app doesnt load in any views whatsoever..

    Has anyone figured out how to fix this?

  12. tom says:

    You’re right, something appears to be broken when using auto layout. However, since you must be using iOS 6 (since auto layout is available to you), I you recommend using UIPageViewController, it is pretty simple to use and you will end up having far less code than this example gives you.
    There is an Apple example to get you started here

    Hope that helps.

  13. [...] as a container UIViewController for scrolling. I used this tutorial (using ARC and storyboards): http://www.wannabegeek.com/?p=168 and the source code: https://github.com/wannabegeek/PageViewController As you can see there are 3 [...]

  14. [...] used this tutorial http://www.wannabegeek.com/?p=168 for swiping between different view controllers (which are on my storyboard). Now I want to unload [...]

  15. Maxime says:

    Hi, Thx you very much for your tuto.

    I just have a short Question. In the View 1, I have added a button and when I click on it, I swipes to a new View. But when I go back to the View 1, the UIPage Control disappears.

    Do you know, how can I fix this issue ?
    Many thx again.
    Maxime.

  16. Paul says:

    First off, brilliant piece of code. I downloaded your example code off of Github and have been digesting this as the main engine to display what will essentially be a type of interactive slide show for one of our pharmaceutical clients. Cutting to the chase, here’s my question:

    I took your base code and implemented a “two finger swipe down” that displays an overlay on the primary PageViewController, which has thumbnail representations of the original 3 viewControllers. In short, displaying the thumbnail menu and clicking on one of the thumbnails will jump the pageViewController directly to that view (instead of having to swipe over multiple views). It works as expected with the exception of one bug I can’t seem to identify. Regardless of the particular viewController that I’m currently on (for this example lets say VIEW #1), if I then display the menu and click on the thumbnail representing VIEW #3, I’m properly taken to VIEW #3 however there’s a bit of confusion in the viewDidAppear arena where it gets called more than once. To be clear, if I swipe from view to view I don’t experience this issue. It’s only when I use my overlay thumbnail navigation, click on a thumbnail and then go to that slide. Here’s some output starting from view #1 and jumping to view #3 by clicking on the thumbnail:

    VIEW 1: viewWillAppear
    VIEW 2: viewWillAppear
    VIEW 1: viewDidDisappear
    VIEW 2: viewDidAppear
    VIEW 3: viewWillAppear
    VIEW 2: viewDidDisappear
    VIEW 3: viewDidAppear
    VIEW 3: viewDidDisappear
    VIEW 3: viewDidAppear

    I’m less concerned with what’s going on with VIEW 2, and more concerned with VIEW 3′s viewDidAppear being called twice. If I put anything in that method it get’s called twice and screws up any animation builds that I might have in the view.

    So, if you’ve got any suggestions as to where I should look to squish that, I would be eternally grateful. Additionally, since your the guru behind this awesome invention, what are your thoughts on limitations to the number of viewControllers one could have in an App?

  17. Andrew says:

    Is there a way to add another ViewController?

    To bad that this doesnt work: [self addChildViewController:[self.storyboard instantiateViewControllerWithIdentifier:@"View4"]];

  18. Osman says:

    i’ve managed to make it work with auto layout. this is how: i realized that when i rotate the emulator it works well. so i copied the code on willAnimateRotationToInterfaceOrientation to viewDidAppear method. so it works now. i hope it helps. thank you for the great code by the way.

  19. Paul says:

    On one of my ViewControllers I’ve embedded a small TableView. Now, this isn’t a complete TableViewController, but rather a TableView within a ViewController which allows me to have other things on the screen simultaneously. That ViewController also serves as delegate and datasource for the TableView, and the data is hard coded at this time. It works as expected; swipe over to that ViewController, clicking on a cell properly displays the TableViewDetails through a custom segue. Closing the TableViewDetail returns you back to the ViewController that contains the TableView.

    Now, here is where the issue presents itself. Once I return back to the ViewController from the TableViewDetail, I can no longer swipe between the individual ViewControllers anymore. I can go back into other TableViewDetails, but my ability to swipe away from that one ViewController is gone. If I hadn’t gone into the TableViewDetail, I can swipe back and forth all day long, but the very first time I return from a TableViewDetail, I’m stuck on that single ViewController. The ViewDidAppear method does get called after I’ve returned out of the TableViewDetail, but I don’t know what I need to do to get the ViewController’s controls back.

    Suggestions?

  20. sophia says:

    Hi i just want to say thank you so much for this tutorial! It’s really really helped me out.
    I wanted to put a button on each of my view controllers to then segue to another page and then back again.
    I managed to do this but when i go back the original view the scroll stops working.

    I dont even know what to search to be able to get help for this.

    If any one could help i would be so grateful!
    Thanks again for the tutorial.

  21. Yesid says:

    Thanks, it is awesome, with some changes works very well vertically.

  22. Paul Scarnegie says:

    Need help returning from a storyboard segue.

    I took a fresh instance of your demo files and added a simple table view to the 3rd view (the light blue one). The items in that table view (when clicked) then display a detail view via a modal segue. So now my storyboard contains your original pageviewcontroller, the 3 original views that you can swipe between and 1 additional viewcontroller that is only displayed when a user clicks on an item in the table that was added to the third view.

    The problem I have is that when I dismiss the table detail view, the pageviewcontroller automatically moves me back to the first view, and not the third view where the table is.

    How do I dismiss a modal view and have the pageviewcontroller stay where I was when I left?

  23. Miguel says:

    Great code, thank you!

    Is it possible to add a button to “show” another view (for exemple: jump to the first view)? I’m trying to do it but it stops paging.

    Thanks

  24. Osman Saral says:

    Hello Miguel,

    There is nextPage and previousPage methods in the code. And here is the code to jump to any page:

    Note that i only modified the changePage IBAction method

    -(void)changePageToPageNumber:(int)pageNumber {
    int page = pageNumber;

    self.pageControl.currentPage = page;
    // update the scroll view to the appropriate page
    CGRect frame = self.scrollView.frame;
    frame.origin.x = frame.size.width * page;
    frame.origin.y = 0;

    UIViewController *oldViewController = [self.childViewControllers objectAtIndex:_page];
    UIViewController *newViewController = [self.childViewControllers objectAtIndex:self.pageControl.currentPage];
    [oldViewController viewWillDisappear:YES];
    [newViewController viewWillAppear:YES];

    [self.scrollView scrollRectToVisible:frame animated:YES];

    // Set the boolean used when scrolls originate from the UIPageControl. See scrollViewDidScroll: above.
    _pageControlUsed = YES;
    }

Leave a Reply