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?
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.