Animate CAShapeLayer path on animated bounds change
Asked Answered
S

4

43

I have a CAShapeLayer (which is the layer of a UIView subclass) whose path should update whenever the view's bounds size changes. For this, I have overridden the layer's setBounds: method to reset the path:

@interface CustomShapeLayer : CAShapeLayer
@end

@implementation CustomShapeLayer

- (void)setBounds:(CGRect)bounds
{
    [super setBounds:bounds];
    self.path = [[self shapeForBounds:bounds] CGPath];
}

This works fine until I animate the bounds change. I would like to have the path animate alongside any animated bounds change (in a UIView animation block) so that the shape layer always adopts to its size.

Since the path property does not animate by default, I came up with this:

Override the layer's addAnimation:forKey: method. In it, figure out if a bounds animation is being added to the layer. If so, create a second explicit animation for animating the path alongside the bounds by copying all the properties from the bounds animation that gets passed to the method. In code:

- (void)addAnimation:(CAAnimation *)animation forKey:(NSString *)key
{
    [super addAnimation:animation forKey:key];

    if ([animation isKindOfClass:[CABasicAnimation class]]) {
        CABasicAnimation *basicAnimation = (CABasicAnimation *)animation;

        if ([basicAnimation.keyPath isEqualToString:@"bounds.size"]) {
            CABasicAnimation *pathAnimation = [basicAnimation copy];
            pathAnimation.keyPath = @"path";
            // The path property has not been updated to the new value yet
            pathAnimation.fromValue = (id)self.path;
            // Compute the new value for path
            pathAnimation.toValue = (id)[[self shapeForBounds:self.bounds] CGPath];

            [self addAnimation:pathAnimation forKey:@"path"];
        }
    }
}

I got this idea from reading David Rönnqvist's View-Layer Synergy article. (This code is for iOS 8. On iOS 7, it seems that you have to check the animation's keyPath against @"bounds" and not `@"bounds.size".)

The calling code that triggers the view's animated bounds change would look like this:

[UIView animateWithDuration:1.0 delay:0.0 usingSpringWithDamping:0.3 initialSpringVelocity:0.0 options:0 animations:^{
    self.customShapeView.bounds = newBounds;
} completion:nil];

Questions

This mostly works, but I am having two problems with it:

  1. Occasionally, I get a crash on (EXC_BAD_ACCESS) in CGPathApply() when triggering this animation while a previous animation is still in progress. I am not sure whether this has anything to do with my particular implementation. Edit: never mind. I forgot to convert UIBezierPath to CGPathRef. Thanks @antonioviero!

  2. When using a standard UIView spring animation, the animation of the view's bounds and the layer's path is slightly out of sync. That is, the path animation also performs in a springy way, but it does not follow the view's bounds exactly.

  3. More generally, is this the best approach? It seems that having a shape layer whose path is dependent on its bounds size and which should animate in sync with any bounds changes is something that should just work™ but I'm having a hard time. I feel there must be a better way.

Other things I have tried

  • Override the layer's actionForKey: or the view's actionForLayer:forKey: in order to return a custom animation object for the path property. I think this would be the preferred way but I did not find a way to get at the transaction properties that should be set by the enclosing animation block. Even if called from inside an animation block, [CATransaction animationDuration] etc. always return the default values.

Is there a way to (a) determine that you are currently inside an animation block, and (b) to get the animation properties (duration, animation curve etc.) that have been set in that block?

Project

Here's the animation: The orange triangle is the path of the shape layer. The black outline is the frame of the view hosting the shape layer.

Screenshot

Have a look at the sample project on GitHub. (This project is for iOS 8 and requires Xcode 6 to run, sorry.)

Update

Jose Luis Piedrahita pointed me to this article by Nick Lockwood, which suggests the following approach:

  1. Override the view's actionForLayer:forKey: or the layer's actionForKey: method and check if the key passed to this method is the one you want to animate (@"path").

  2. If so, calling super on one of the layer's "normal" animatable properties (such as @"bounds") will implicitly tell you if you are inside an animation block. If the view (the layer's delegate) returns a valid object (and not nil or NSNull), we are.

  3. Set the parameters (duration, timing function, etc.) for the path animation from the animation returned from [super actionForKey:] and return the path animation.

This does indeed work great under iOS 7.x. However, under iOS 8, the object returned from actionForLayer:forKey: is not a standard (and documented) CA...Animation but an instance of the private _UIViewAdditiveAnimationAction class (an NSObject subclass). Since the properties of this class are not documented, I can't use them easily to create the path animation.

_Edit: Or it might just work after all. As Nick mentioned in his article, some properties like backgroundColor still return a standard CA...Animation on iOS 8. I'll have to try it out.`

Sherl answered 24/7, 2014 at 14:47 Comment(8)
path is an animatable property. And I don't really think this is the best approach. As you can see yourself, it looks flimsy. I hope to come up with a solution in a few minutes.Euphonize
@duci9y: Yes, but it does animate if you just set the path from inside an animation block. You have to create an explicit animation and add it to the layer. Where should I create this animation in order to have the path automatically animate when it gets changed in an animation block?Sherl
I would do everything manually using CADisplayLink – IMO it would eliminate most of the issues.Ensepulcher
When you look at the animation in slow-mo, it seems that CAShapeLayer's built-in path interpolation simply ignores any progress values that are larger than 1.0, i.e. it doesn't support "overshooting" animations, which would be required for spring effects. Just an observation, not sure if this is documented anywhere or if it's possible to work around this.Kadi
@Kadi That's my observation as well. If log what is happening the path animation is a correctly configured CASpringAnimation but it behaves as a basic animation. If you apply the same technique for another layer property (like opacity or transform) it does oscillate as a one would expect it to.Wellborn
As for #2, I would say that the path isn't springy. It's just an illusion of the bounds jumping around that makes the path seem to jump around as well.Wellborn
The project works in Xcode 5 and iOS 7. You can get the current animating properties from the layer's presentationLayer. @CocoaPriest Also, tried using CADisplayLink, but it occasionally caused a blue screen on a 64 bit device.Euphonize
I don't know if there is a right solution, but maybe you could take exactly the same approach as you do for the path itself and add the animations after setting the path in the bounds setter. [super setBounds:bounds]is where animations will be added (be they implicit or UIView animations), so you can read the necessary values from the layer "bounds" animation if it exists. Unfortunately I can confirm from experience that path animations will be clamped when using springy timing functions. (Also keep in mind that UIView animation block and CATransaction are 2 different things)Hemi
V
7

I know this is an old question but I can provide you a solution which appears similar to Mr Lockwoods approach. Sadly the source code here is swift so you will need to convert it to ObjC.

As mentioned before if the layer is a backing layer for a view you can intercept the CAAction's in the view itself. This however isn't convenient for example if the backing layer is used in more then one view.

The good news is actionForLayer:forKey: actually calls actionForKey: in the backing layer.

It's in the actionForKey: in the backing layer where we can intercept these calls and provide an animation for when the path is changed.

An example layer written in swift is as follows:

class AnimatedBackingLayer: CAShapeLayer
{

    override var bounds: CGRect
    {
        didSet
        {
            if !CGRectIsEmpty(bounds)
            {
                path = UIBezierPath(roundedRect: CGRectInset(bounds, 10, 10), cornerRadius: 5).CGPath
            }
        }
    }

    override func actionForKey(event: String) -> CAAction?
    {
        if event == "path"
        {
            if let action = super.actionForKey("backgroundColor") as? CABasicAnimation
            {
                let animation = CABasicAnimation(keyPath: event)
                animation.fromValue = path
                // Copy values from existing action
                animation.autoreverses = action.autoreverses
                animation.beginTime = action.beginTime
                animation.delegate = action.delegate
                animation.duration = action.duration
                animation.fillMode = action.fillMode
                animation.repeatCount = action.repeatCount
                animation.repeatDuration = action.repeatDuration
                animation.speed = action.speed
                animation.timingFunction = action.timingFunction
                animation.timeOffset = action.timeOffset
                return animation
            }
        }
        return super.actionForKey(event)
    }

}
Valente answered 29/10, 2015 at 9:37 Comment(3)
I changed actionForKey to animationForKey, and it worksUnpaged
Thank you so much for this! Works like a charm, even with spring animations by casting the action to CASpringAnimation.Min
I can't get this to work. Does anyone have an example project?Antitragus
S
3

I think you have problems because you play with the frame of a layer and it's path at the same time.

I would just go with CustomView that has custom drawRect: that draws what you need, and then just do

[UIView animateWithDuration:1.0 delay:0.0 usingSpringWithDamping:0.3 initialSpringVelocity:0.0 options:0 animations:^{
    self.customView.bounds = newBounds;
} completion:nil];

Sor the is no need to use pathes at all

Here is what i've got using this approach https://dl.dropboxusercontent.com/u/73912254/triangle.mov

Susumu answered 12/8, 2014 at 13:40 Comment(0)
R
2

Found solution for animating path of CAShapeLayer while bounds is animated:

typedef UIBezierPath *(^PathGeneratorBlock)();

@interface AnimatedPathShapeLayer : CAShapeLayer

@property (copy, nonatomic) PathGeneratorBlock pathGenerator;

@end

@implementation AnimatedPathShapeLayer

- (void)addAnimation:(CAAnimation *)anim forKey:(NSString *)key {
    if ([key rangeOfString:@"bounds.size"].location == 0) {
        CAShapeLayer *presentationLayer = self.presentationLayer;
        CABasicAnimation *pathAnim = [anim copy];
        pathAnim.keyPath = @"path";
        pathAnim.fromValue = (id)[presentationLayer path];
        pathAnim.toValue = (id)self.pathGenerator().CGPath;
        self.path = [presentationLayer path];
        [super addAnimation:pathAnim forKey:@"path"];
    }
    [super addAnimation:anim forKey:key];
}

- (void)removeAnimationForKey:(NSString *)key {
    if ([key rangeOfString:@"bounds.size"].location == 0) {
        [super removeAnimationForKey:@"path"];
    }
    [super removeAnimationForKey:key];
}

@end

//

@interface ShapeLayeredView : UIView

@property (strong, nonatomic) AnimatedPathShapeLayer *layer;

@end

@implementation ShapeLayeredView

@dynamic layer;

+ (Class)layerClass {
    return [AnimatedPathShapeLayer class];
}

- (instancetype)initWithGenerator:(PathGeneratorBlock)pathGenerator {
    self = [super init];
    if (self) {
        self.layer.pathGenerator = pathGenerator;
    }
    return self;
}

@end
Revival answered 3/3, 2016 at 7:38 Comment(2)
Can you also translate this into Swift? I'm having trouble making this work in Swift.Deal
@HennyLee removed @keypath usage with simple string constants. This should help to convert to Swift.Revival
R
1

I think it is out of sync between bounds and path animation is because different timing function between UIVIew spring and CABasicAnimation.

Maybe you can try animate transform instead, it should also transform the path (untested), after it finished animating, you can then set the bound.

One more possible way is take snapshot the path, set it as content of the layer, then animate the bound, the content should follow the animation then.

Raylenerayless answered 16/11, 2014 at 8:7 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.