Animating CALayer's shadowPath property
Asked Answered
S

4

10

I am aware that a CALayer's shadowPath is only animatable using explicit animations, however I still cannot get this to work. I suspect that I am not passing the toValue properly - as I understand this has to be an id, yet the property takes a CGPathRef. Storing this in a UIBezierPath does not seem to work. I am using the following code to test:

CABasicAnimation *theAnimation = [CABasicAnimation animationWithKeyPath:@"shadowPath"];
theAnimation.duration = 3.0;
theAnimation.toValue = [UIBezierPath bezierPathWithRect:CGRectMake(-10.0, -10.0, 50.0, 50.0)];
[self.view.layer addAnimation:theAnimation forKey:@"animateShadowPath"];

(I am using minus values so as to ensure the shadow extends beyond a view that lies on top of it... the layer's masksToBounds property is set to NO).

How is animation of the shadowPath achieved?

UPDATE

Problem nearly solved. Unfortunately, the main problem was a somewhat careless error...

The mistake I made was to add the animation to the root layer of the view controller, rather than the layer I had dedicated to the shadow. Also, @pe8ter was correct in that the toValue needs to be a CGPathRef cast to id (obviously when I had tried this before I still had no animation due to the wrong layer mistake). The animation works with the following code:

CABasicAnimation *theAnimation = [CABasicAnimation animationWithKeyPath:@"shadowPath"];
theAnimation.duration = 3.0;
theAnimation.toValue = (id)[UIBezierPath bezierPathWithRect:myRect].CGPath;
[controller.shadowLayer addAnimation:theAnimation forKey:@"shadowPath"];

I appreciate this was difficult to spot from the sample code I provided. Hopefully it can still be of use to people in a similar situation though.

However, when I try and add the line

controller.shadowLayer.shadowPath = [UIBezierPath bezierPathWithRect:myRect].CGPath;

the animation stops working, and the shadow just jumps to the final position instantly. Docs say to add the animation with the same key as the property being changed so as to override the implicit animation created when setting the value of the property, however shadowPath can't generate implicit animations... so how do I get the new property to stay after the animation?

Swashbuckling answered 7/5, 2011 at 23:39 Comment(4)
Sorry that didn't help. The only alternative I can think of is using CALayer's delegate to set the property explicitly after animation. See the this post.Legra
@pe8ter: So close! I followed this great suggestion, but there is a very slight flash of the original shadowPath before animationDidStop: intercepts the animation and sets the frame. So frustrating. This could be because I have to iterate through a small array of view controllers to identify the correct layer for each animation, although I suspect the delegate method is just being fired that fraction of a second too late, after the shadowPath has already been set to its original value.Swashbuckling
It's hard to say what's going wrong with your code. I mocked-up a little test program and tried both ways; they worked as expected with no visual artifacts.Legra
Also, use addAnimation:forKey: to give a unique name for your animations. The key parameter doesn't have to be the same as the one you specify in animationWithKeyPath:. See this. Sorry I couldn't help more.Legra
L
9

Firstly, you did not set the animation's fromValue.

Secondly, you're correct: toValue accepts a CGPathRef, except it needs to be cast to id. Do something like this:

theAnimation.toValue = (id)[UIBezierPath bezierPathWithRect:newRect].CGPath;

You'll also need to set the shadowPath property of the layer explicitly if you want the change to remain after animation.

Legra answered 8/5, 2011 at 10:1 Comment(3)
Thanks for the reply. I didn't set the fromValue because according to the docs it is optional (and I am animating from the current path). Casting to id, while suppressing the compiler warning, has still not solved my problem. True enough point about explicitly setting the shadowPath property to retain the change after animating, however I'm not seeing any animation in the first place, so I presume the problem must lie elsewhere.Swashbuckling
Getting there now - was carelessly sending the animation to the wrong layer. Having trouble with retaining the new value for the shadowPath after the animation though - I have updated my question.Swashbuckling
Time to eat some humble pie. Your very first suggestion of setting the fromValue has solved my problem. I must admit it does seem like an odd subtlety to me, but by explicitly declaring the fromValue as the current path for some reason allows the animation to proceed properly when the property is set. I wish the docs were more clear about this. Thanks for all of your help.Swashbuckling
S
1
let cornerRadious = 10.0
        //
        let shadowPathFrom = UIBezierPath(roundedRect: rect1, cornerRadius: cornerRadious)
        let shadowPathTo = UIBezierPath(roundedRect: rect2, cornerRadius: cornerRadious)
        //
        layer.masksToBounds = false
        layer.shadowColor = UIColor.yellowColor().CGColor
        layer.shadowOpacity = 0.6
        //
        let shadowAnimation = CABasicAnimation(keyPath: "shadowPath")
        shadowAnimation.fromValue = shadowPathFrom.CGPath
        shadowAnimation.toValue = shadowPathTo.CGPath
        shadowAnimation.duration = 0.4
        shadowAnimation.autoreverses = true
        shadowAnimation.removedOnCompletion = true
        shadowAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
        layer.addAnimation(shadowAnimation, forKey: "shadowAnimation")
Schuh answered 6/8, 2015 at 11:21 Comment(0)
G
0

I've met same problem for shadow animation and find solution for persisting shadow after end of animation. You need to set final path to your shadow layer before you start animation. Reverting to initial shadow happens because CoreAnimation does not update properties of original layer, it creates copy of this layer and display animation on this copy (check Neko1kat answer for more details). After animation ended system removes this animated layer and return original layer, that has not updated path and your old shadow appears. Try this code:

    let shadowAnimation = CABasicAnimation(keyPath: "shadowPath")
    shadowAnimation.fromValue = currentShadowPath
    shadowAnimation.toValue = newShadowPath
    shadowAnimation.duration = 3.0

    shadowLayer.shadowPath = newShadowPath
    shadowLayer.add(shadowAnimation, forKey: "shadowAnimation")
Groceryman answered 7/4, 2020 at 11:52 Comment(0)
P
0

Although not directly answer this question, if you just needs a view can drop shadow, you can use my class directly.

/*
 Shadow.swift

 Copyright © 2018, 2020-2021 BB9z
 https://github.com/BB9z/iOS-Project-Template

 The MIT License
 https://opensource.org/licenses/MIT
 */

/**
 A view drops shadow.
 */
@IBDesignable
class ShadowView: UIView {
    @IBInspectable var shadowOffset: CGPoint = CGPoint(x: 0, y: 8) {
        didSet { needsUpdateStyle = true }
    }
    @IBInspectable var shadowBlur: CGFloat = 10 {
        didSet { needsUpdateStyle = true }
    }
    @IBInspectable var shadowSpread: CGFloat = 0 {
        didSet { needsUpdateStyle = true }
    }
    /// Set nil can disable shadow
    @IBInspectable var shadowColor: UIColor? = UIColor.black.withAlphaComponent(0.3) {
        didSet { needsUpdateStyle = true }
    }
    @IBInspectable var cornerRadius: CGFloat {
        get { layer.cornerRadius }
        set { layer.cornerRadius = newValue }
    }

    private var needsUpdateStyle = false {
        didSet {
            guard needsUpdateStyle, !oldValue else { return }
            DispatchQueue.main.async { [self] in
                if needsUpdateStyle { updateLayerStyle() }
            }
        }
    }

    private func updateLayerStyle() {
        needsUpdateStyle = false
        if let color = shadowColor {
            Shadow(view: self, offset: shadowOffset, blur: shadowBlur, spread: shadowSpread, color: color, cornerRadius: cornerRadius)
        } else {
            layer.shadowColor = nil
            layer.shadowPath = nil
            layer.shadowOpacity = 0
        }
    }

    override func prepareForInterfaceBuilder() {
        super.prepareForInterfaceBuilder()
        updateLayerStyle()
    }

    override func layoutSublayers(of layer: CALayer) {
        super.layoutSublayers(of: layer)
        lastLayerSize = layer.bounds.size
        if shadowColor != nil, layer.shadowOpacity == 0 {
            updateLayerStyle()
        }
    }

    private var lastLayerSize = CGSize.zero {
        didSet {
            if oldValue == lastLayerSize { return }
            guard shadowColor != nil else { return }
            updateShadowPathWithAnimationFixes(bonuds: layer.bounds)
        }
    }

    // We needs some additional step to achieve smooth result when view resizing
    private func updateShadowPathWithAnimationFixes(bonuds: CGRect) {
        let rect = bonuds.insetBy(dx: shadowSpread, dy: shadowSpread)
        let newShadowPath = UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius).cgPath
        if let resizeAnimation = layer.animation(forKey: "bounds.size") {
            let key = #keyPath(CALayer.shadowPath)
            let shadowAnimation = CABasicAnimation(keyPath: key)
            shadowAnimation.duration = resizeAnimation.duration
            shadowAnimation.timingFunction = resizeAnimation.timingFunction
            shadowAnimation.fromValue = layer.shadowPath
            shadowAnimation.toValue = newShadowPath
            layer.add(shadowAnimation, forKey: key)
        }
        layer.shadowPath = newShadowPath
    }
}

/**
 Make shadow with the same effect as Sketch app.
 */
func Shadow(view: UIView?, offset: CGPoint, blur: CGFloat, spread: CGFloat, color: UIColor, cornerRadius: CGFloat = 0) {  // swiftlint:disable:this identifier_name
    guard let layer = view?.layer else {
        return
    }
    layer.shadowColor = color.cgColor
    layer.shadowOffset = CGSize(width: offset.x, height: offset.y)
    layer.shadowRadius = blur
    layer.shadowOpacity = 1
    layer.cornerRadius = cornerRadius

    let rect = layer.bounds.insetBy(dx: spread, dy: spread)
    layer.shadowPath = UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius).cgPath
}

via https://github.com/BB9z/iOS-Project-Template/blob/master/App/General/Effect/Shadow.swift

Providing answered 29/1, 2021 at 6:23 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.