Travis Griggs Travis Griggs - 3 months ago 7
iOS Question

Correct way to subclass CAShapeLayer

Inspired by this example, I have a created custom

CALayer
subclasses for wedges and arcs. The allow me to draw arcs and wedges and animate changes in them so that they sweep radially.

One of the frustrations with them, is that apparently when you go this route of having a subclass
drawInContext()
you are limited by the clip of the layer's frame. With stock layers, you have the
masksToBounds
which is by default
false
! But it seems once you the subclass route with drawing, that because implicitly and unchangeably
true
.

So I thought I would try a different approach, by subclassing
CAShapeLayer
instead. And instead of adding a custom
drawInContext()
, I would simply have my variables for
start
and
sweep
update the
path
of the receiver. This works nicely BUT it will no longer animate as it used to:

import UIKit

class WedgeLayer:CAShapeLayer {
var start:Angle = 0.0.rotations { didSet { self.updatePath() }}
var sweep:Angle = 1.0.rotations { didSet { self.updatePath() }}
dynamic var startRadians:CGFloat { get {return self.start.radians.raw } set {self.start = newValue.radians}}
dynamic var sweepRadians:CGFloat { get {return self.sweep.radians.raw } set {self.sweep = newValue.radians}}

// more dynamic unit variants omitted
// we have these alternate interfaces because you must use a type
// which the animation framework can understand for interpolation purposes

override init(layer: AnyObject) {
super.init(layer: layer)
if let layer = layer as? WedgeLayer {
self.color = layer.color
self.start = layer.start
self.sweep = layer.sweep
}
}

override init() {
super.init()
}

required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

func updatePath() {
let center = self.bounds.midMid
let radius = center.x.min(center.y)
print("updating path \(self.start) radius \(radius)")

if self.sweep.abs < 1.rotations {
let _path = UIBezierPath()
_path.moveToPoint(center)
_path.addArcWithCenter(center, radius: radius, startAngle: self.start.radians.raw, endAngle: (self.start + self.sweep).radians.raw, clockwise: self.sweep >= 0.radians ? true : false)
_path.closePath()
self.path = _path.CGPath
}
else {
self.path = UIBezierPath(ovalInRect: CGRect(around: center, width: radius * 2, height: radius * 2)).CGPath
}
}

override class func needsDisplayForKey(key: String) -> Bool {
return key == "startRadians" || key == "sweepRadians" || key == "startDegrees" || key == "sweepDegrees" || key == "startRotations" || key == "sweepRotations" || super.needsDisplayForKey(key)
}
}


Is it not possible to make it regenerate the path and update as it animates the value? With the
print()
statement there, I can see it interpolating through the values as expected during an animation. I have tried adding
setNeedsDisplay()
in various locations, but to no avail.

Answer

There are a couple tricks to getting this to work properly:

  • Don't use the didSet. Rather, you should override display() and update self.path there.

  • Use (presentationLayer() as! WedgeLayer? ?? self).sweep to access the property.

    • Where using presentationLayer gives you the currently animated value
  • If you want implicit animation, implement actionForKey as described in this answer.