How to write an endless UIScrollView in Swift 2

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

Part I: Thinking outside the box

There come times when programming on iOS, where one wishes to think outside the box. On this particular day I sat down at my computer, and I was faced with a problem. A user of an app I wrote a long time ago, sent me an email containing a feature request. I'd never received an email from a user before today, so you could imagine my surprise when it was a feature request, and not a 'I hate you and your app sucks!' curtesy message. Therefore I was determined to give it my fullest attention, and come up with a solution.

In a nutshell, the user was frustrated by the fact that iOS, unlike Android and Windows Phone 😱, does not have infinitely scrolling UIScrollView instances provided to developers out of the box. Of course the user didn't word their request so technically, but there you are.

A quick search of Github, Google, and of course (the almost omnipresent) StackOverflow, revealed to me that many had tried to solve this problem and indeed done so, but their solutions had issues or were written almost five years ago. I decided that I would write my own, in Swift. 🎉

Step 1: Let's do it!

We'll be making something similar to this:

Hyperbole and a Half

Right, the first thing you're going to want to do, is create a new project in Xcode which uses the Single View Application template. Once this is done, create a new Cocoa Touch Class file, call it JAScrollView and ensure that its Subclass is UIScrollView.

Open it up, select everything, then paste this in:

import Foundation  
import UIKit

class JAScrollView : UIScrollView, UIScrollViewDelegate {

    // 1
    var viewObjects: [UIView]?   
    var numPages: Int = 0

    // 2
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)

        pagingEnabled = true
        showsHorizontalScrollIndicator = false
        showsVerticalScrollIndicator = false
        scrollsToTop = false
        delegate = self
    }

    // 3
    func setup() {
        guard let parent = superview else { return }

        contentSize = CGSize(width: (frame.size.width * (CGFloat(numPages) + 2)), height: frame.size.height)

        loadScrollViewWithPage(0)
        loadScrollViewWithPage(1)
        loadScrollViewWithPage(2)

        var newFrame = frame
        newFrame.origin.x = newFrame.size.width
        newFrame.origin.y = 0
        scrollRectToVisible(newFrame, animated: false)

        layoutIfNeeded()
    }

    // 4
    private func loadScrollViewWithPage(page: Int) {
        if page < 0 { return }
        if page >= numPages + 2 { return }

        var index = 0

        if page == 0 {
            index = numPages - 1
        } else if page == numPages + 1 {
            index = 0
        } else {
            index = page - 1
        }

        let view = viewObjects?[index]

        var newFrame = frame
        newFrame.origin.x = frame.size.width * CGFloat(page)
        newFrame.origin.y = 0
        view?.frame = newFrame

        if view?.superview == nil {
            addSubview(view!)
        }

        layoutIfNeeded()
    }

    // 5
    @objc internal func scrollViewDidScroll(scrollView: UIScrollView) {       
        let pageWidth = frame.size.width
        let page = floor((contentOffset.x - (pageWidth/2)) / pageWidth) + 1

        loadScrollViewWithPage(Int(page - 1))
        loadScrollViewWithPage(Int(page))
        loadScrollViewWithPage(Int(page + 1))
    }

    // 6
    @objc internal func scrollViewDidEndDecelerating(scrollView: UIScrollView) {        
        let pageWidth = frame.size.width
        let page : Int = Int(floor((contentOffset.x - (pageWidth/2)) / pageWidth) + 1)

        if page == 0 {
            contentOffset = CGPoint(x: pageWidth*(CGFloat(numPages)), y: 0)
        } else if page == numPages+1 {
            contentOffset = CGPoint(x: pageWidth, y: 0)
        }
    }

}
Breakdown:
  • // 1: Variables that we'll use for the entire class.
  • // 2: Our initialiser. We need this because we're using Interface Builder to keep things simple.
  • // 3: Our setup() function adds the views you connected to the Scroll View for you. It also sets its own contentSize.
  • // 4: Our loadScrollViewWithPage() function which takes a page parameter. In a nutshell it adds the required pages to the Scroll View.
  • // 5: Method implemented from UIScrollViewDelegate. The didScroll method is responsible for updating the scroll view as you move.
  • // 6: Method implemented from UIScrollViewDelegate. The didEndDecelerating method gets called when the scroll view stops moving, and then updates the content offset if you're at the end of the pageable content. This is the key.
Step 2: Hook it up!

Go ahead and open up your ViewController.swift file, or whichever UIViewController you want this kind of scroll view contained within. This will likely be a "Menu" or your "Onboarding" screen.

Declare yourself an @IBOutlet with a weak var reference, named scrollView and of type JAScrollView?, then save.


Now open up your Storyboard, find the associated ViewController board, and drag a UIScrollView object onto it, then give it AutoLayout constraints as follows:


Wire up the @IBOutlet you created before to this scroll view object, then save. Make sure that the Scroll View object has its class set to JAScrollView too.


Now open up your ViewController class again, and find the viewDidLoad() method. Highlight the method, including func viewDidLoad() { and the trailing }, then paste the following in:

override func viewDidLoad() {  
  super.viewDidLoad()

  // 1
  let view1 = UIView(frame: CGRect(x: 0, y: 0, width: view.frame.size.width, height: view.frame.size.height))
  view1.backgroundColor = .redColor()

  let view2 = UIView(frame: CGRect(x: 0, y: 0, width: view.frame.size.width, height: view.frame.size.height))
  view2.backgroundColor = .greenColor()

  let view3 = UIView(frame: CGRect(x: 0, y: 0, width: view.frame.size.width, height: view.frame.size.height))
  view3.backgroundColor = .blueColor()

  // 2        
  scrollView?.numPages = 3
  scrollView?.viewObjects = [view1, view2, view3]

  // 3
  scrollView?.setup()

}
Breakdown:
  • // 1: Yes I lied, we're not using Interface Builder for this bit, but we will soon! 🙏 We'll create three UIView objects, each with different colours.
  • // 2: We tell the scroll view how many pages we want, then give it the views objects in an Array<UIView> object.
  • // 3: Then we call setup() which sets up the thing! 🎉

If you did the thing properly, if you run the app in the Simulator you'll see your first page displayed. A quick swipe to the left gives you the 2nd page, and a swipe to the right gives you the last one.

Note: This can still break if you go too quickly at this stage. It's designed to be paged at a regular pace.

Step 3: No UIPageControl?

Hyperbole and a Half

You'll notice that there's no UIPageControl with the page dots. I believe this control is important to tell the user that you can scroll, so we'll add that now.

  • Add this to your variables:

    var pageControl: UIPageControl?

  • Then. replace the setup() method with this:

func setup() {  
        guard let parent = superview else { return }

        contentSize = CGSize(width: (frame.size.width * (CGFloat(numPages) + 2)), height: frame.size.height)

        pageControl = UIPageControl(frame: CGRect(x: 0, y: parent.frame.size.height-25, width: parent.frame.size.width, height: 25))
        pageControl?.numberOfPages = numPages
        pageControl?.currentPage = 0
        pageControl?.addTarget(self, action: "changePage:", forControlEvents: .TouchDown)
        pageControl?.userInteractionEnabled = false
        parent.addSubview(pageControl!)

        loadScrollViewWithPage(0)
        loadScrollViewWithPage(1)
        loadScrollViewWithPage(2)

        var newFrame = frame
        newFrame.origin.x = newFrame.size.width
        newFrame.origin.y = 0
        scrollRectToVisible(newFrame, animated: false)

        layoutIfNeeded()
}
  • And replace your didScroll delegate method with this:
@objc internal func scrollViewDidScroll(scrollView: UIScrollView) {        
        let pageWidth = frame.size.width
        let page = floor((contentOffset.x - (pageWidth/2)) / pageWidth) + 1
        pageControl?.currentPage = Int(page - 1)

        loadScrollViewWithPage(Int(page - 1))
        loadScrollViewWithPage(Int(page))
        loadScrollViewWithPage(Int(page + 1))
}

Run the app and see that, yes!! It shows the page dots! 🎉


Step 4: Interface Builder again

If you don't already feel like you can do anything, open up your ViewController file again, and delete the lines from your viewDidLoad() function which add those fake, coloured UIView objects like so:

From this:
Mountain View

To this: (ignore the cursor)


Then open up JAScrollView.swift and change var viewObjects: [UIView]? to @IBOutlet var viewObjects: [UIView]?


Now, open up your Storyboard and add some extra UIView objects to your UIViewController's container, like so:

On the first View, add a 100x100 UILabel with the a word of your choosing. In the picture the word is Emoji. (🇯🇵 絵文字, Picture Character)

In the second View, add another label of the same size, with a different word.

Then, wire up the scroll view's viewObjects property to each one of the views.

This class is designed to handle 3+ UIView objects. Any less and we'll start to see some issues. But we'll rectify this later.

Now run the app again. This time, the views you connected in Interface Builder are now the panels of your scroll view! You'll see it doesn't work perfectly, but that's what Part II is for.

I'll post the Source Code.....later. After all, learning's still cool, right? 😉


In Part II of this tutorial, we'll enable page switching by tapping on the UIPageControl object and do other, further optimisations. 👍