Custom CALayer

Episode #290 | 13 minutes | published on July 21, 2017 | Uses swift-3.0, Xcode-8.3
In this video we’ll learn how to use custom drawing with CALayers to support implicit animations.

Links

## Setting Up the Playground with a View Controller

We want some interactivity this time, so we’ll use a view controller.

import UIKit
import PlaygroundSupport

final class ProgressView: UIView {
  var progress: CGFloat = 0
}

final class ViewController: UIViewController {
    let progressView = ProgressView()

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .white

        progressView.progress = 0.31
        progressView.backgroundColor = .clear

        progressView.frame = view.bounds
        progressView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        view.addSubview(progressView)
    }
}

PlaygroundPage.current.liveView = ViewController()

Here we have a custom view that has a progressView as a subview. We'll tap on the main view to increment the progress of the progress view.

       // in viewDidLoad...
        let tap = UITapGestureRecognizer(target: self, action: #selector(increment))
        view.addGestureRecognizer(tap)

And then we can use that handler to increment a new property:

    @objc private func increment() {
        progressView.progress += 0.1
    }

Creating a Custom Layer-backed View

We want to do the drawing with a custom CALayer, so we'll create that first:

private final class ProgressLayer: CALayer {
   var progress: CGFloat = 0 {
       didSet { setNeedsDisplay() }
   }

   private let fillColor = UIColor.blue.cgColor

    override init() {
        super.init()
    }

    override init(layer: Any) {
        super.init(layer: layer)

        guard let progressLayer = layer as? ProgressLayer else { return }

        progress = progressLayer.progress
    }

    override func draw(in context: CGContext) {
        context.setFillColor(fillColor)

        var rect = bounds
        rect.size.width *= progress
        context.fill(rect)
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError()
    }
}

We want to use this layer as our view's layer, and also forward any progress changes over to the layer.

var progress: CGFloat {
    get {
        return progressLayer?.progress ?? 0
    }

    set {
        progressLayer?.progress = newValue
    }
}

override class var layerClass: AnyClass {
    return ProgressLayer.self
}

private var progressLayer: ProgressLayer? {
    return layer as? ProgressLayer
}

Supporting Animations

In order to support animations, we'll have to make a few changes. First, our property needs to by marked as @NSManaged:

class ProgressLayer : CALayer {
    @NSManaged var progress: CGFloat
    ...
}

Then we need to tell the system that when the progress changes, we need to redraw:

    override class func needsDisplay(forKey key: String) -> Bool {
        if key == "progress" {
            return true
        }

        return super.needsDisplay(forKey: key)
    }

And we need return an CAAction for when this key changes, which will likely be an animation:

    override func action(forKey key: String) -> CAAction? {
        if key == "progress" {
            let animation = CABasicAnimation(keyPath: key)
            animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
            animation.fromValue = presentation()?.value(forKey: key)
            return animation
        }

        return super.action(forKey: key)
    }

Note that we use the presentation() instead of the progress on self directly. CALayers have a presentation layer which hold the current values that are being displayed, whereas the layer just contains the starting/ending values for any animation.

Consider for instance moving a box’s X position from 0 to 100. The layer would get this value set to 100 immediately, but the presentation layer would have its value reflect the in between animation state. By doing this we can ensure our animation starts from wherever it currently is on screen, rather than resetting it back to the start position.

We’ll have to use this presentation value as well when we are drawing:

    let progress = presentation()?.progress ?? 0

    var rect = bounds
    rect.size.width *= progress
    context.fill(rect)
blog comments powered by Disqus