Animating CALayer shadow simultaneously as UITableviewCell height animates

calayer, core-animation, objective-c, uitableview

Solution

So I guess you have something like this:

(I turned on “Debug > Slow Animations” in the simulator.) And you don't like the way the shadow jumps to its new size. You want this instead:

You can find my test project in this github repository.

See @horseshoe7's answer for a Swift translation.

It is tricky but not impossible to pick up the animation parameters and add an animation in the table view's animation block. The trickiest part is that you need to update the `shadowPath` in the `layoutSubviews` method of the shadowed view itself, or of the shadowed view's immediate superview. In my demo video, that means that the `shadowPath` needs to be updated by the `layoutSubviews` method of the green box view or the green box's immediate superview.

I chose to create a `ShadowingView` class whose only job is to draw and animate the shadow of one of its subviews. Here's the interface:

@interface ShadowingView : UIView

@property (nonatomic, strong) IBOutlet UIView *shadowedView;

@end

To use `ShadowingView`, I added it to my cell view in my storyboard. Actually it's nested inside a stack view inside the cell. Then I added the green box as a subview of the `ShadowingView` and connected the `shadowedView` outlet to the green box.

The `ShadowingView` implementation has three parts. One is its `layoutSubviews` method, which sets up the layer shadow properties on its own layer to draw a shadow around its `shadowedView` subview:

@implementation ShadowingView

- (void)layoutSubviews {
    [super layoutSubviews];

    CALayer *layer = self.layer;
    layer.backgroundColor = nil;

    CALayer *shadowedLayer = self.shadowedView.layer;
    if (shadowedLayer == nil) {
        layer.shadowColor = nil;
        return;
    }

    NSAssert(shadowedLayer.superlayer == layer, @"shadowedView must be my direct subview");

    layer.shadowColor = UIColor.blackColor.CGColor;
    layer.shadowOffset = CGSizeMake(0, 1);
    layer.shadowOpacity = 0.5;
    layer.shadowRadius = 3;
    layer.masksToBounds = NO;

    CGFloat radius = shadowedLayer.cornerRadius;
    layer.shadowPath = CGPathCreateWithRoundedRect(shadowedLayer.frame, radius, radius, nil);
}

When this method is run inside an animation block (as is the case when the table view animates a change in the size of a cell), and the method sets `shadowPath`, Core Animation looks for an “action” to run after updating `shadowPath`. One of the ways it looks is by sending `actionForLayer:forKey:` to the layer's delegate, and the delegate is the `ShadowingView`. So we override `actionForLayer:forKey:` to provide an action if possible and appropriate. If we can't, we just call `super`.

It is important to understand that Core Animation asks for the action from inside the `shadowPath` setter, before actually changing the value of `shadowPath`.

To provide the action, we make sure the key is `@"shadowPath"`, that there is an existing value for `shadowPath`, and that there is already an animation on the layer for `bounds.size`. Why do we look for an existing `bounds.size` animation? Because that existing animation has the duration and timing function we should use to animate `shadowPath`. If everything is in order, we grab the existing `shadowPath`, make a copy of the animation, store them in an action, and return the action:

- (id<CAAction>)actionForLayer:(CALayer *)layer forKey:(NSString *)event {
    if (![event isEqualToString:@"shadowPath"]) { return [super actionForLayer:layer forKey:event]; }

    CGPathRef priorPath = layer.shadowPath;
    if (priorPath == NULL) { return [super actionForLayer:layer forKey:event]; }

    CAAnimation *sizeAnimation = [layer animationForKey:@"bounds.size"];
    if (![sizeAnimation isKindOfClass:[CABasicAnimation class]]) { return [super actionForLayer:layer forKey:event]; }

    CABasicAnimation *animation = [sizeAnimation copy];
    animation.keyPath = @"shadowPath";
    ShadowingViewAction *action = [[ShadowingViewAction alloc] init];
    action.priorPath = priorPath;
    action.pendingAnimation = animation;
    return action;
}

@end

What does the action look like? Here's the interface:

@interface ShadowingViewAction : NSObject <CAAction>
@property (nonatomic, strong) CABasicAnimation *pendingAnimation;
@property (nonatomic) CGPathRef priorPath;
@end

The implementation requires a `runActionForKey:object:arguments:` method. In this method, we update the animation that we created in `actionForLayer:forKey:` using the saved-away old `shadowPath` and the new `shadowPath`, and then we add the animation to the layer.

We also need to manage the retain count of the saved path, because ARC doesn't manage `CGPath` objects.

@implementation ShadowingViewAction

- (void)runActionForKey:(NSString *)event object:(id)anObject arguments:(NSDictionary *)dict {
    if (![anObject isKindOfClass:[CALayer class]] || _pendingAnimation == nil) { return; }
    CALayer *layer = anObject;
    _pendingAnimation.fromValue = (__bridge id)_priorPath;
    _pendingAnimation.toValue = (__bridge id)layer.shadowPath;
    [layer addAnimation:_pendingAnimation forKey:@"shadowPath"];
}

- (void)setPriorPath:(CGPathRef)priorPath {
    CGPathRetain(priorPath);
    CGPathRelease(_priorPath);
    _priorPath = priorPath;
}

- (void)dealloc {
    CGPathRelease(_priorPath);
}

@end

Problem

I have a UITableView that I am attempting to expand and collapse using its `beginUpdates` and `endUpdates` methods and have a drop shadow displayed as that's happening. In my custom UITableViewCell, I have a layer which I create a shadow for in `layoutSubviews`: ``` self.shadowLayer.shadowColor = self.shadowColor.CGColor; self.shadowLayer.shadowOffset = CGSizeMake(self.shadowOffsetWidth, self.shadowOffsetHeight); self.shadowLayer.shadowOpacity = self.shadowOpacity; self.shadowLayer.masksToBounds = NO; self.shadowLayer.frame = self.layer.bounds; // this is extremely important for performance when drawing shadows UIBezierPath *shadowPath = [UIBezierPath bezierPathWithRoundedRect:self.shadowLayer.frame cornerRadius:self.cornerRadius]; self.shadowLayer.shadowPath = shadowPath.CGPath; ``` I add this layer to the UITableViewCell in `viewDidLoad`: ``` self.shadowLayer = [CALayer layer]; self.shadowLayer.backgroundColor = [UIColor whiteColor].CGColor; [self.layer insertSublayer:self.shadowLayer below:self.contentView.layer]; ``` As I understand it, when I call `beginUpdates`, an implicit `CALayerTransaction` is made for the current run loop if none exists. Additionally, `layoutSubviews` also gets called. The problem here is that the resulting shadow is drawn immediately based on the new size of the UITableViewCell. I really need to shadow to continue to cast in the expected way as the actual layer is animating. Since my created layer is not a backing CALayer it animates without explicitly specifying a CATransaction, which is expected. But, as I understand it, I really need some way to grab hold of `beginUpdates`/`endUpdates` CATransaction and perform the animation in that. How do I do that, if at all?

Original source