Search code examples
ioscore-animationcalayercgpathibinspectable

CALayer with Animated Path Content, Make Inspectable/Designable


I made a custom circular progress view by subclassing UIView, adding a CAShapeLayer sublayer to its layer and overriding drawRect() to update the shape layer's path property.

By making the view @IBDesignable and the progress property @IBInspectable, I was able to edit its value in Interface Builder and see the updated bezier path in real time. Non-essential, but really cool!

Next, I decided to make the path animated: Whenever you set a new value in code, the arc indicating the progress should "grow" from zero length to whatever percentage of the circle is achieved (think of arcs in the Activity app in apple Watch).

To achieve this, I swapped my CAShapeLayer sublayer by a custom CALayer subclass that has a @dynamic (@NSManaged) property, observed as key for ananimation (I implemented needsDisplayForKey(), actionForKey(), drawInContext() etc. ).

My View code (the relevant parts) looks something like this:

// Triggers path update (animated)
private var progress: CGFloat = 0.0 {
    didSet {
        updateArcLayer()
    }
}

// Programmatic interface:
// (pass false to achieve immediate change)
func setValue(newValue: CGFloat, animated: Bool) {
    if animated {
        self.progress = newValue
    } else {
        arcLayer.animates = false
        arcLayer.removeAllAnimations()
        self.progress = newValue
        arcLayer.animates = true
    }
}

// Exposed to Interface Builder's  inspector: 
@IBInspectable var currentValue: CGFloat {
    set(newValue) {
        setValue(newValue: currentValue, animated: false)
        self.setNeedsLayout()
    }
    get {
        return progress
    }
}

private func updateArcLayer() {
    arcLayer.frame = self.layer.bounds
    arcLayer.progress = progress
}

And the layer code:

var animates: Bool = true
@NSManaged var progress: CGFloat

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

override func action(forKey event: String) -> CAAction? {
    if event == "progress" && animates == true {
        return makeAnimation(forKey: event)
    }
    return super.action(forKey: event)
}

override func draw(in ctx: CGContext) {
    ctx.beginPath()

    // Define the arcs...
    ctx.closePath()
    ctx.setFillColor(fillColor.cgColor)
    ctx.drawPath(using: CGPathDrawingMode.fill)
}

private func makeAnimation(forKey event: String) -> CABasicAnimation? {
    let animation = CABasicAnimation(keyPath: event)

    if let presentationLayer = self.presentation() {
        animation.fromValue = presentationLayer.value(forKey: event)
    }

    animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)
    animation.duration = animationDuration
    return animation
}

The animation works, but now I can't get my paths to show in Interface Builder.

I have tried implementing my view's prepareForInterfaceBuilder() like this:

override func prepareForInterfaceBuilder() {
    super.prepareForInterfaceBuilder()

    self.topLabel.text = "Hello, Interface Builder!"
    updateArcLayer()
}

...and the label text change is reflected in Interface Builder, but the path isn't rendered.

Am I missing something?


Solution

  • Well, isn't it funny... It turns out the declaration of my @Inspectable property had a very silly bug.

    Can you spot it?

    @IBInspectable var currentValue: CGFloat {
        set(newValue) {
            setValue(newValue: currentValue, animated: false)
            self.setNeedsLayout()
        }
        get {
            return progress
        }
    }
    

    It should be:

    @IBInspectable var currentValue: CGFloat {
        set(newValue) {
            setValue(newValue: newValue, animated: false)
            self.setNeedsLayout()
        }
        get {
            return progress
        }
    }
    

    That is, I was discarding the passed value (newValue) and using the current one (currentValue) to set the internal variable instead. "Logging" the value (always 0.0) to Interface Builder (via my label's text property!) gave me a clue.

    Now it is working fine, no need for drawRect() etc.