Ok, probably this question has been asked 1000 times on SO. And I must have tried 500 of the answers. Nothing works, which makes me think this might not be possible.
Before I even start, I don't want a animable custom property, I just want to draw on bounds change. Most of the answers treat the former only. But what if my content depends only on the size of the layer and nothing more?
Is this really not possible? I tried:
override class func needsDisplay(forKey key: String) -> Bool
needsDisplayOnBoundsChange
action(for:)
or animation(for:)
methodcontentMode = .redraw
setBounds
and adding an implicit animation (which is useless btw since the layer has an animation, it just doesn't redraw on resize)The furthest I've reached: the contents draw at the beginning of the animation and then at the end. Which of course looks bad (also pixelated) when going from large to small.
Not necessarily important, but here is the draw method for reference:
override func draw(in ctx: CGContext) {
UIGraphicsPushContext(ctx)
paths = [Dimension: [UIBezierPath]]()
cuts = [Dimension: [UIBezierPath]]()
let centerTranslate = CGAffineTransform.init(translationX: frame.width * 0.5,
y: frame.height * 0.5)
let dimRotateAndTranslate = centerTranslate.rotated(by: CGFloat.pi / 2)
let needlePath = UIBezierPath(rect: CGRect(x: 0,
y: 0,
width: circularSpacing,
height: frame.height * 0.5 - centerRadius))
let piProgress = CGFloat(progress * 2 * Float.pi)
var needleRotateAndTranslate = CGAffineTransform(rotationAngle: piProgress)
needleRotateAndTranslate = needleRotateAndTranslate.translatedBy(x: -needlePath.bounds.width * 0.5,
y: -needlePath.bounds.height - centerRadius)
needlePath.apply(needleRotateAndTranslate.concatenating(centerTranslate))
let sortedDims = states.sorted(by: {$0.key < $1.key})
for (offset: i, element: (key: dim, value: beats)) in sortedDims.enumerated() {
var pathsAtDim = paths[dim] ?? []
var cutAtDim = cuts[dim] ?? []
let thickness = (frame.width * 0.5 - centerRadius - circularSpacing * CGFloat(states.count - 1)) / CGFloat(states.count)
let xSpacing = thickness + circularSpacing
let radius = centerRadius + thickness * 0.5 + CGFloat(i) * xSpacing
for (offset: j, beat) in beats.enumerated() {
let length = CGFloat.pi * 2 / CGFloat(beats.count) * CGFloat(beat.relativeDuration)
var path = UIBezierPath(arcCenter: .zero,
radius: radius,
startAngle: CGFloat(j) * length,
endAngle: CGFloat(j + 1) * length,
clockwise: true)
path = UIBezierPath(cgPath: path.cgPath.copy(strokingWithWidth: thickness,
lineCap: .butt,
lineJoin: .miter,
miterLimit: 0,
transform: dimRotateAndTranslate))
func cutAtIndex(index: Int, offset: CGFloat) -> UIBezierPath {
let cut = UIBezierPath(rect: CGRect(x: 0,
y: 0,
width: thickness,
height: circularSpacing))
let offsetRadians = offset / radius
let x = (radius - thickness / 2) * cos(CGFloat(index) * length + offsetRadians)
let y = (radius - thickness / 2) * sin(CGFloat(index) * length + offsetRadians)
let translate = CGAffineTransform.init(translationX: x,
y: y)
let rotate = CGAffineTransform.init(rotationAngle: CGFloat(index) * length)
cut.apply(rotate
.concatenating(translate)
.concatenating(dimRotateAndTranslate))
return cut
}
cutAtDim.append(cutAtIndex(index: j, offset: -circularSpacing * 0.5))
pathsAtDim.append(path)
}
paths[dim] = pathsAtDim
cuts[dim] = cutAtDim
}
let clippingMask = UIBezierPath.init(rect: bounds)
for (dim, _) in states {
for cut in cuts[dim] ?? [] {
clippingMask.append(cut.reversing())
}
}
clippingMask.addClip()
for (dim, states) in states {
for i in 0..<states.count {
let state = states[i]
if let path = paths[dim]?[i] {
let color = dimensionsColors[dim]?[state.state] ?? .darkGray
color.setFill()
path.fill()
}
}
}
ctx.resetClip()
needleColor.setFill()
needlePath.fill()
UIGraphicsPopContext()
}
First of all, the question is not very clear in many aspects. If my understanding is correct, you wished that changing the bounds will automatically redraw the CALayer during the animation.
Method one: It can be achieved by a general way which includes setting a timer and slightly changing the bounds (from an array of CGRECT) and redrawing every time. As you can access the drawing function, it should not be a problem for developing such an animation by yourself.
Method two:
The idea is same as before but if you wanna exploit the way of CAAnimation. the following snippet can help you.
In this one, an external timer (CADisplayLink) is used. Also the bounds changing has to be achieved from a function updateBounds
for convenience.
Keep in mind, the struct of snippet should be carefully treated. Any modification may lead to an animation lost.
The successful result will show you continuing redraw during the bounds changing.
The test viewController:
class ViewController: UIViewController {
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
layer1 = MyLayer()
layer1.frame = CGRect.init(x: 0, y: 0, width: 300, height: 500)
layer1.backgroundColor = UIColor.red.cgColor
view.layer.addSublayer(layer1)
layer1.setNeedsDisplay()
}
@IBAction func updateLayer1(){
CATransaction.begin()
CATransaction.setAnimationDuration(3.0)
let nextBounds = CGRect.init(origin: layer1.bounds.origin, size: CGSize.init(width: layer1.bounds.width + 100, height: layer1.bounds.height + 150))
layer1.updateBounds(nextBounds)
CATransaction.commit()
}
}
The subclass of CALayer, only listing the part with bounds changing animation.
class MyLayer: CALayer, CAAnimationDelegate{
func updateBounds(_ bounds: CGRect){
nextBounds = bounds
self.bounds = bounds
}
private var nextBounds : CGRect!
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
if let keyPath = (anim as! CABasicAnimation).keyPath , let displayLink = animationLoops[keyPath]{
displayLink?.invalidate()
animationLoops.removeValue(forKey: keyPath) }
}
@objc func updateDrawing(displayLink: CADisplayLink){
bounds = presentation()!.bounds
setNeedsDisplay()
}
var animationLoops: [String : CADisplayLink?] = [:]
override func action(forKey event: String) -> CAAction? {
let result = super.action(forKey: event)
if(event == "bounds"){
guard let result = result as? CABasicAnimation,
animationLoops[event] == nil
else{return nil}
let anim = CABasicAnimation.init(keyPath: result.keyPath)
anim.fromValue = result.fromValue
anim.toValue = nextBounds
let caDisplayLink = CADisplayLink.init(target: self, selector: #selector(updateDrawing))
caDisplayLink.add(to: .main, forMode: RunLoop.Mode.default)
anim.delegate = self
animationLoops[event] = caDisplayLink
return anim
}
return result
}
Hope this is what you want. Have a nice day.
Comparing Animation: Without Timer: