6

Imagine a CAGradientLayer.

It's very easy to animate .startPoint and .endPoint.

Now imagine a float spinLike which simply sets both of them at once.

{So, instead of having two different animations, you could simply animate spinLike.}

So something like ..

class CustomGradientLayer: CAGradientLayer {
    
    @objc var spinLike: CGFloat = 0 {
        
        didSet {
            
            startPoint = CGPoint( ... )
            endPoint = CGPoint( ... )
            setNeedsDisplay()
        }
    }
}

To animate spinLike...

class Test: UIView {

     ...
     g = CustomGradientLayer()
     a = CABasicAnimation(keyPath: "spinLike")
     ...
     g.add(a, forKey: nil)
     ...

But.

It doesn't work, startPoint and endPoint are not moved at all.

What is wrong?


Note - tragically it seems you can not @NSManaged a property which has a didSet...

enter image description here


Note - it's easy enough to make your own custom animation by just overriding the draw loop.

There are many examples of this around. This is how you do it:

class CircleProgressLayer: CALayer {
     
    @NSManaged var progress: CGFloat

    override class func needsDisplayForKey(key: String) -> Bool {
         
        if key == "progress" {
            return true
        }
        return super.needsDisplayForKey(key)
    }
    
    override func draw(in ctx: CGContext) {
        
        path.fill() etc etc... your usual drawing code
    }
}

Unfortunately my question here is

Unrelated to actual drawing:

By animating the property spinLike,

I simply want to change each frame existing ordinary animatable properties (in the example, .startPoint and .endPoint )

How do you do this?

Note! You can't change .startPoint and .endPoint in drawInContext - you'd be attempting to modify read-only layer

Community
  • 1
  • 1
Fattie
  • 27,874
  • 70
  • 431
  • 719
  • You should overwrite `draw(in context: CGContext)` and not `drawInContext(ctx: CGContext)`. – clemens Dec 10 '17 at 18:35
  • (Hmm, that's not it @clemens - I did in fact test `draw#in` - I just typed the old one out of habit ... :) ) – Fattie Dec 10 '17 at 18:51
  • Have you made any progress on your problem? If `startPoint` and `endPoint` are always related to `spinLike` you can drop these properties and compute their values in `draw(in:)`. – clemens Dec 18 '17 at 06:54

1 Answers1

2

To animate custom properties, you should mark them with @NSManaged. You should not force redrawing when you assign a new value. Instead, you should overwrite needsDisplay(forKey:).

class CustomedGradLayer: CAGradientLayer {
    @NSManaged var spinLike: CGFloat

    class func needsDisplay(forKey key: String) -> Bool {
        return key == "spinLike" || super.needsDisplay(forKey: key)
    }

    class func defaultValue(forKey key: String) -> Any? {
        return key == "spinLike" ? CGFloat(0) : super.defaultValue(forKey: key)
    }
}

Finally, you should implement the drawing of the layer according to the Apple documentation.

I wrote a small project in Swift some months ago. It demonstrates custom layer animations with the depth of a Koch curve.

This is the code of the layer class:

class KochLayer: CALayer {
    fileprivate let kPI = CGFloat(Double.pi)
    @NSManaged var depth : CGFloat
    var midPoint: CGPoint {
        get {
            let theBounds = self.bounds

            return CGPoint(x: theBounds.midX, y: theBounds.midY)
        }
    }
    var color: CGColor!

    override class func defaultValue(forKey inKey: String) -> Any? {
        return inKey == kDepthKey ? 0.0 : super.defaultValue(forKey: inKey)
    }

    override class func needsDisplay(forKey inKey: String) -> Bool {
        if inKey == kDepthKey {
            return true
        }
        else {
            return super.needsDisplay(forKey: inKey)
        }
    }

    override init() {
        super.init()
    }

    override init(layer inLayer: Any) {
        super.init(layer: inLayer)
        if let theLayer = inLayer as? KochLayer {
            depth = theLayer.depth
            color = theLayer.color
        }
    }

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

    func pointWithRadius(_ inRadius: CGFloat, angle inAngle: CGFloat) -> CGPoint {
        let theCenter = midPoint

        return CGPoint(x: theCenter.x + inRadius * sin(inAngle),
            y: theCenter.y - inRadius * cos(inAngle));
    }

    override func draw(in inContext: CGContext) {
        let theBounds = self.bounds
        let theRadius = fmin(theBounds.width, theBounds.height) / 2.0
        let thePoints: [CGPoint] = [
            pointWithRadius(theRadius, angle:0.0),
            pointWithRadius(theRadius, angle:2 * kPI / 3.0),
            pointWithRadius(theRadius, angle:4 * kPI / 3.0)
        ]
        let thePath = CGMutablePath()

        inContext.setLineWidth(0.5)
        inContext.setLineCap(.round)
        inContext.setLineJoin(.round)
        inContext.setFillColor(color)
        thePath.move(to: thePoints[0])
        for i in 0..<3 {
            addPointsToPath(thePath, fromPoint:thePoints[i], toPoint:thePoints[(i + 1) % 3], withDepth:self.depth)
        }
        inContext.addPath(thePath)
        inContext.fillPath()
    }

    func addPointsToPath(_ inoutPath: CGMutablePath, fromPoint inFromPoint: CGPoint, toPoint inToPoint: CGPoint, withDepth inDepth: CGFloat) {
        var thePoints = Array<CGPoint>(repeating: inFromPoint, count: 5)

        thePoints[4] = inToPoint;
        if inDepth <= 1.0 {
            curveWithWeight(inDepth, points:&thePoints)
            for i in 1..<5 {
                inoutPath.addLine(to: thePoints[i])
            }
        }
        else {
            let theDepth = inDepth - 1;

            curveWithWeight(1.0, points:&thePoints)
            for i in 0..<4  {
                addPointsToPath(inoutPath, fromPoint:thePoints[i], toPoint:thePoints[i + 1], withDepth:theDepth)
            }
        }
    }

    func curveWithWeight(_ inWeight: CGFloat, points inoutPoints: inout [CGPoint]) {
        let theFromPoint = inoutPoints[0]
        let theToPoint = inoutPoints[4]
        let theFactor = inWeight / (2 * sqrt(3))
        let theDelta = CGSize(width: theToPoint.x - theFromPoint.x, height: theToPoint.y - theFromPoint.y);

        inoutPoints[1] = CGPoint(x: theFromPoint.x + theDelta.width / 3,
            y: theFromPoint.y + theDelta.height / 3)
        inoutPoints[2] = CGPoint(x: theFromPoint.x + theDelta.width / 2 + theFactor * theDelta.height,
            y: theFromPoint.y + theDelta.height / 2 - theFactor * theDelta.width);
        inoutPoints[3] = CGPoint(x: theToPoint.x - theDelta.width / 3,
            y: theToPoint.y - theDelta.height / 3)
    }
}
clemens
  • 16,716
  • 11
  • 50
  • 65
  • `CGFloat` is based on `double` and not on a class in Objective C. Thus, you can't use an optional for this. You should declare it according to the example in my answer without question mark. – clemens Dec 03 '17 at 15:13
  • 1
    A small (not very important) note here... Maybe `dynamic` would be a better candidate than `@NSManaged` here, since Apple explicitly associates the later with CoreData ('The Swift Programming Language / Swift 4 / Page 874) – Alladinian Dec 04 '17 at 14:41
  • 2
    @Alladinian the corresponding Objective-C code would be `@dynamic` which is different from the dynamic keyword in Swift, so unfortunately `dynamic` won't work here. – David Rönnqvist Dec 04 '17 at 14:43
  • @DavidRönnqvist Since I'm familiar with your knowledge on this subject, I guess that I stand corrected! What is the difference tho? – Alladinian Dec 04 '17 at 14:49
  • 2
    @Alladinian Swift's `dynamic` means that access will be dynamically dispatched using the Objective-C message sending (even after SE-0160 decoupled `@obc` and `dynamic` this remains true in practice since there is no alternative runtime). Objective-C's `@dynamic` means that the property's accessors will be provided dynamically (the class will figure it out at runtime). Core Animation creates its own accessors for animatable properties, but I'm having a hard time finding a reference that explains why that is. I seem to recall that it's to call `needsDisplay(forKey:)`, `action(forKey:)`, etc. – David Rönnqvist Dec 04 '17 at 15:19
  • I've updated my post with a working Swift example. Unfortunately, I can't attach the complete example, because SO doesn't support attachments. – clemens Dec 10 '17 at 18:31
  • hi @clemens - thanks again - Hmm; it's possible I didn't explain myself well ... in your example, the custom property is `.depth`. I believe, in your draw#in function, you are **not** modifying any other animatable properties of the CALayer (for example, `.backgroundColor`). That's the problem I face.... – Fattie Dec 10 '17 at 18:49
  • Thus, in your example, in the draw function, just add something which modifies any property of the layer each call. (Example, .backgroundColor = white with alpha (depth % 1.0) ) It will crash with the "attempting to modify read-only layer" error. – Fattie Dec 10 '17 at 18:54
  • You shouldn't modify layer properties in the drawing function, because drawing is done by the presentation layer of model layer. The model layer is the layer of the view, and the presentation layer is a copy of that layer. You should modify all layer properties inside of the animation block. – clemens Dec 10 '17 at 19:04
  • right !!! but it would seem incredibly sensible that you can have a property, which merely adjusts two other properties, and that animating that one will animate the other two! @clemens – Fattie Dec 11 '17 at 20:33
  • The `draw(in:)` method is exactly for that operation. It shouldn't change the layer's state. – clemens Dec 12 '17 at 06:21
  • I'm trying to add `@NSManaged` to `CGAffineTransform` struct, but receive runtime crash `-[NSConcreteValue transformStruct]: unrecognized selector sent to instance 0x600003623070`. – Timur Bernikovich Mar 21 '19 at 21:09
  • @TimurBernikovich: You should create your own question with a code example. This is difficult to answer in the comment area. – clemens Mar 22 '19 at 09:08