Replicating UIScrollView

UIScrollView might be one of the most sentitive part of the iOS ecosystem. It provides the overall solution to display large content on the small screens of the iOS devices. In fact, UIScrollView is rarely used as-it. It is the superclass of several UIKit classes including UITableView and UITextView. You could find thousands of tutorials on the web on how to easily take advantage of it.

In this article, I would like to go a little bit deeper and expound on how an UIScrollView actually works. To do so, we will try to create a humble copy of it. Our starting point is a basic UIView subclass:

class MyScrollView: UIView {

}

Of course, UIScrollView has a lot of features: paging, content inset, layout guides, content touches delay etc. We will not be able to reproduce all of them here. We will focus our efforts on the main UIScrollView abilities:

Let’s get started!

Scroll View Content

If you already play a bit with a scroll view, you already know that its content is simply its subviews. Let’s add some subviews!

class MyScrollView: UIView {

    override init(frame: CGRect) {
        super.init(frame: frame)
        setUpView()
    }

    private func setUpView() {
        let colors: [UIColor] = [.purple, .red]
        let height: CGFloat = 200
        for i in 0..<100 {
            let view = UIView(
                frame: CGRect(
                    x: 0, 
                    y: CGFloat(i) * height, 
                    width: bounds.width, 
                    height: height
                )
            )
            view.backgroundColor = colors[i%colors.count]
            addSubview(view)
        }

    }
}

IMAGE

Scrolling

How to slide subviews ?

We first need to detect the user interactions. The answer is the UIScrollView API. It has a public pan gesture recognizer that looks for dragging gestures. Let’s add it in our own subclass:

class MyScrollView: UIView {

    let panGestureRecognizer = UIPanGestureRecognizer()

    override init(frame: CGRect) {
        super.init(frame: frame)
        setUpView()
    }

    @objc private func panGestureUpdate(_ sender: UIPanGestureRecognizer) {
        // ...
    }

    private func setUpView() {
        // ...
        panGestureRecognizer.addTarget(self, action: #selector(panGestureUpdate(_:)))
        addGestureRecognizer(panGestureRecognizer)
    }
}

There are mulitple ways to slide subviews. A first naive panGestureUpdate implementation may look like this:

class MyScrollView: UIView {

    @objc private func panGestureUpdate(_ sender: UIPanGestureRecognizer) {
        let translation = sender.translation(in: self).y
        switch sender.state {
        case .changed:
            subviews.forEach { $0.frame.origin.y += translation }
        case ...:
            break
        }
        sender.setTranslation(.zero, in: self)
    }
}

Each time panGestureUpdate is triggered, we update the frame for each of the subviews. It works!

IMAGE

But, this solution has an obvious drawback. This forEach might have a huge performance cost. We can do way better knowing some ordinary UIView features.

A view is placed relatively to its superview’s coordinate system. This latter has the origin at its top left by default but we can easily change it:

class MyScrollView: UIView {

    @objc private func panGestureUpdate(_ sender: UIPanGestureRecognizer) {
        let translation = sender.translation(in: self).y
        switch sender.state {
        case .changed:
            bounds.origin.y += translation
        case ...:
            break
        }
        sender.setTranslation(.zero, in: self)
    }
}

Indeed bounds.origin describes the view’s location in its own coordinate system. Changing it modifies the scroll view coordinate system’s origin and slides all its subviews accordingly.

In order to stick a bit more to the original API, we can now define the contentOffset property:

class MyScrollView: UIView {

    var contentOffset: CGPoint {
        set {
            bounds.origin = newValue
        }
        get {
            return bounds.origin
        }
    }

    @objc private func panGestureUpdate(_ sender: UIPanGestureRecognizer) {
        let translation = sender.translation(in: self).y
        switch sender.state {
        case .changed:
            contentOffset.y += translation
        case ...:
            break
        }
        sender.setTranslation(.zero, in: self)
    }
}

It is a simple computed property which directly accesses the bounds’s origin.

IMAGE

Content Size

For now the content has no limit. We should fix that.

UIScrollView has a contentSize property which definese how far it can slide its subviews. Let’s add it:

class MyScrollView: UIView {

    var contentSize: CGSize = .zero

    @objc func panGestureAction(_ sender: UIPanGestureRecognizer) {
        // ...
        var offset = contentOffset
        offset.y -= translation
        contentOffset = _rubberBandContentOffset(forOffset: offset)
        // ...
    }

    func _rubberBandContentOffset(forOffset offset: CGPoint) -> CGPoint {
        return max(min(contentSize.height - bounds.height, contentOffset.y), 0)
    }
}

The limit is reached when the scroll view’s bounds origin is CGPoint.zero or when the entire content has scrolled.

Animations

Interacting with a native scroll view is great. All the motions feel natural. When we reach the end of content or when we left our finger, the content doesn’t scroll lineary.

To known how far it should be allowed to slide its subviews upward and leftward. That is the scroll view’s content size — its contentSize property.