How to write an endless UIScrollView in Swift 2 | Part II: UIPageViewController

We build professional apps for Android, iOS and mobile web.
Have a look

Part II: The unsuitability of Solution A with larger, more complex use cases.

Disclaimer: This post assumes you've looked at and maybe even completed Part I.

Following on from Part I where we made an infinitely paging UIScrollView with Storyboards and Swift, in this part we'll be looking at how to improve it and use it for more complex use cases.

After I posted Part I onto Reddit, a user commented with what Part II was about before I'd even started writing it. The comment was "Why not just use a Page View Controller?"

A truer statement had never been typed. However, the entire point of Part I was to use an infinitely paging UIScrollView for basic use cases, such as the displaying of sequential images. Nevertheless, Part I's solution is thoroughly unsuitable for a lot of situations.

One example would be the dreaded Onboarding process of an application. It's difficult to get right, and disastrous if you get it wrong. Often, you'll need to display some information combined with some animation which serves to highlight the function of the app to the user.

A UIScrollView wouldn't be suitable for this. You want to keep your UIViewController files small and compact, not at all unwieldy. And that's where UIPageViewController comes to the rescue.


Step 1: One more time (let's do it again!)


We'll be making this:

We'll start off by opening Xcode and creating a new project, using the Single View Application template.

Then, open ViewController.swift and change its class to UIPageViewController. Also in the Storyboard, delete the ViewController you find there, and replace it with a UIPageViewController object. Make sure this new object is is the Initial View Controller and that the class is set to your ViewController.

You'll want to go in and make sure the Page Controller's "Navigation" is Horizontal, that the "Transition Style" is set to Scroll and that the "Spine Location" is set to None.

NB: You can make the direction vertical too. You'll just need to rotate the UIPageControl we add down below to better reflect the interaction method.

Create a new Swift file in Xcode, and call it Indexable and inside it, paste the following:

// 1
import UIKit

extension UIViewController {

  private struct AssociatedKeys {
    static var id : Int?
  }

  var id: Int {
    get {
      return objc_getAssociatedObject(self, &AssociatedKeys.id) as! Int
    } set {
      objc_setAssociatedObject(self, &AssociatedKeys.id, newValue as Int, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC)
    }
  }    
}

Back in the Storyboard, add two UIViewController instances to it. Give them unique background colours, such as .cyanColor() and .magentaColor(), and suitable Storyboard Identifiers such as "First" and "Second," then save.

Breakpoint
  • 1:
    We're using Associated Objects to mimic stored properties in a Swift extension. This allows us to add an id property to every UIViewController instance in our app. Feel free to make this a String or even some kind of enum value, but I'm just going to use Int.

After this, in your ViewController.swift file, replace the contents with the following:

class ViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate {

  // 1
  override func viewDidLoad() {
    super.viewDidLoad()

    delegate = self
    dataSource = self

    setViewControllers([viewControllerAtIndex(0)], direction: .Forward, animated: false, completion: nil)
  }

  // 2
  func viewControllerAtIndex(index: Int) -> UIViewController {
    var vc: UIViewController?    
      switch index {
        case 0:
          vc = UIStoryboard(name: "Main", bundle: nil).instantiateViewControllerWithIdentifier("First")
          vc?.id = index
          vc?.view.backgroundColor = .cyanColor()
        case 1:
          vc = UIStoryboard(name: "Main", bundle: nil).instantiateViewControllerWithIdentifier("Second")
          vc?.id = index
          vc?.view.backgroundColor = .redColor()
        default: break
      }    
    return vc!
  }

  // 3
  func pageViewController(pageViewController: UIPageViewController, viewControllerBeforeViewController viewController: UIViewController) -> UIViewController? {
    let index = viewController.id    
      switch index {
        case 0:
          return viewControllerAtIndex(1)
        case 1:
          return viewControllerAtIndex(0)
        default:
          return viewControllerAtIndex(0)
      }
  }

  // 4    
  func pageViewController(pageViewController: UIPageViewController, viewControllerAfterViewController viewController: UIViewController) -> UIViewController? {
    let index = viewController.id
    switch index {
      case 0:
        return viewControllerAtIndex(1)
      case 1:
        return viewControllerAtIndex(0)
      default:
        return viewControllerAtIndex(1)
    }
  }
}
Breakpoint
  • 1:
    We want to make sure the delegate and dataSource properties are set to the current class. You can of course externalise these into other objects/files but to keep things simple, we'll just put them in here.

  • 2:
    This function returns a UIViewController object based on the index you provide to it. It then sets the index you provide to be the UIViewController's id property, which we added above. If you don't want to use Associated Objects, a protocol and a single subclass of UIViewController would do nicely, but again to keep things simple, I'm doing things this way, in order to avoid creating too many files.

  • 3:
    viewControllerBeforeViewController fills the immediate before screen of the UIPageViewController with whatever UIViewController should come before the one you're viewing.

  • 4:
    viewControllerAfterViewController fills the immediate after screen of the UIPageViewConroller with whatever should come after the current one.

NB: This tutorial only caters for two UIViewController instances to be held in the UIPageViewController. If you want to have more, you'll need to configure the code I provide to make it happen. Feel free to @ me on Twitter and ask questions if needed.


If you run the app now, you'll notice that it works perfectly, but there're no dots to indicate the current page.

Horizontal

Vertical

NB: Excuse the frame-rate of the GIFs. They are not indicative of actual App performance.


Step 2: I'll get you my pretty, and your little UIPageControl too!

One thing you'll notice if you use two provided UIPageViewControllerDelegate methods to add a UIPageControl to the screen, is that has a dirty great .blackColor() background on it.

func presentationCountForPageViewController(pageViewController: UIPageViewController) -> Int {  
  return count // You'll need to add this.
}

func presentationIndexForPageViewController(pageViewController: UIPageViewController) -> Int {  
  return currentIndex // You'll need to add and track this too.
}

If you want this, then great! That means you're done and you can close the tab! 🎉 But everyone else who does not want this, please keep reading.

We're going to take those methods out, and add our own UIPageControl object, relevant constraints on it and the method to handle paging.

Firstly, add a UIPageControl object to your variables.

@IBOutlet weak var pageControl: UIPageControl!

And then, you want to make use of this handy Delegate method from the UIPageViewController.

// 1
func pageViewController(pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {  
  if completed {
    switch previousViewControllers[0].id {
      case 0: pageControl.currentPage = 1
      case 1: pageControl.currentPage = 0
      default: break
    }
  }
}

After that, we'll need to setup the page control object so that it's actually on the screen and adding to the UX of our app. So let's do that now. Open up the storyboard and add a UIPageControl to your UIPageViewController's container, then wire it up to your @IBOutlet. Set the number of pages to 2 for the purposes of this tutorial.

Add these lines to your viewDidLoad() function:

// 2
pageControl.translatesAutoresizingMaskIntoConstraints = false

view.addSubview(pageControl)

// 3
view.addConstraint(NSLayoutConstraint(item: pageControl, attribute: .Leading, relatedBy: .Equal, toItem: view, attribute: .Leading, multiplier: 1, constant: 0))  
view.addConstraint(NSLayoutConstraint(item: pageControl, attribute: .Trailing, relatedBy: .Equal, toItem: view, attribute: .Trailing, multiplier: 1, constant: 0))  
view.addConstraint(NSLayoutConstraint(item: pageControl, attribute: .Bottom, relatedBy: .Equal, toItem: view, attribute: .Bottom, multiplier: 1, constant: 0))

// 4
view.layoutIfNeeded()  
Breakpoint
  • 1:
    didFinishAnimating is brilliant for this purpose because it has a previousViewControllers array. This is despite UIPageViewController seemingly only able to support the one view controller at a time (?). You can use this array and check the kind of UIViewController that it holds, and use that to update your UIPageControl accordingly. We're using a switch on our newly added id Associated Object to determine which dot to highlight, so you'll need to expand/change/replace this method so as to make it support your setup.

  • 2:
    translatesAutoresizingMaskIntoConstraints when set to true, will happily block us from programatically adding constraints by making everything conflict and printing some totally readable console messages in Xcode for us. So let's set that to false for the sake of our nerves.

  • 3:
    Add some basic NSLayoutConstraints to pin the UIPageControl to the leading and trailing of the superview. We'll also pin the object to the bottom of the screen.

  • 4:
    Tell the view you want to new stuff laid out.

NB: I opted against the use of Visual Format Language for the above constraints, as it tends to be harder to read for developers less familiar with AutoLayout.


Final thoughts

So there we have it. Two solutions, both of which are suitable for their relevant use cases.

If you have any more suggestions to improve this, then please feel free to @ me on Twitter (@cocotutch).

Thanks! 🎉