Search code examples
ioscore-graphicscore-animation

Animate a CALayer contents in its draw method


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
  • overriding every kind of action(for:) or animation(for:) method
  • contentMode = .redraw
  • tons of hacks like overriding setBounds and adding an implicit animation (which is useless btw since the layer has an animation, it just doesn't redraw on resize)
  • maybe a couple of other things I forgot, spent quite a while on this

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()
    }

Solution

  • 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:

    enter image description here

    Adding CADsiaplayLinker Timer: enter image description here