Animating CALayer shadow simultaneously as UITableviewCell height animates
Asked Answered
P

4

5

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?

Pablo answered 12/5, 2017 at 18:8 Comment(0)
F
24

So I guess you have something like this:

bad animation

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

good animation

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
Fussy answered 13/5, 2017 at 23:16 Comment(4)
Rob, this is terrific! This was exactly the solution I was looking for. I translated it to Swift and will post it here too.Lolalolande
You get an upvote, @Lolalolande gets an upvote. This both solved my problem and taught me something new about CoreAnimation.Resplendence
Super Amazing... Thanks to both.. Rob for his amazing work.. HorseShoe7 for Swift translation!Aldis
What an amazing answer! just learned the CAAction from this answer! Thanks Rob!Telegony
L
9

This is Rob Mayoff's answer written in Swift. Could save someone some time.

Please don't upvote this. Upvote Rob Mayoff's solution. It is awesome, and correct. (Note from mayoff: why not upvote both? 😉)

import UIKit

class AnimatingShadowView: UIView {

    struct DropShadowParameters {
        var shadowOpacity: Float = 0
        var shadowColor: UIColor? = .black
        var shadowRadius: CGFloat = 0
        var shadowOffset: CGSize = .zero
        
        static let defaultParameters = DropShadowParameters(shadowOpacity: 0.15,
                                                            shadowColor: .black,
                                                            shadowRadius: 5,
                                                            shadowOffset: CGSize(width: 0, height: 1))
    }
    
    @IBOutlet weak var contentView: UIView!  // no sense in have a shadowView without content!
    
    var shadowParameters: DropShadowParameters = DropShadowParameters.defaultParameters
    
    private func apply(dropShadow: DropShadowParameters) {
        let layer = self.layer
        layer.shadowColor = dropShadow.shadowColor?.cgColor
        layer.shadowOffset = dropShadow.shadowOffset
        layer.shadowOpacity = dropShadow.shadowOpacity
        layer.shadowRadius = dropShadow.shadowRadius
        layer.masksToBounds = false
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        let layer = self.layer
        layer.backgroundColor = nil
        
        let contentLayer = self.contentView.layer
        assert(contentLayer.superlayer == layer, "contentView must be a direct subview of AnimatingShadowView!")
        
        self.apply(dropShadow: self.shadowParameters)
        
        let radius = contentLayer.cornerRadius
        layer.shadowPath = UIBezierPath(roundedRect: contentLayer.frame, cornerRadius: radius).cgPath
    }
    
    override func action(for layer: CALayer, forKey event: String) -> CAAction? {
        guard event == "shadowPath" else {
            return super.action(for: layer, forKey: event)
        }
        
        guard let priorPath = layer.shadowPath else {
            return super.action(for: layer, forKey: event)
        }
        
        guard let sizeAnimation = layer.animation(forKey: "bounds.size") as? CABasicAnimation else {
            return super.action(for: layer, forKey: event)
        }
        
        let animation = sizeAnimation.copy() as! CABasicAnimation
        animation.keyPath = "shadowPath"
        let action = ShadowingViewAction()
        action.priorPath = priorPath
        action.pendingAnimation = animation
        return action
    }
}


private class ShadowingViewAction: NSObject, CAAction {
    var pendingAnimation: CABasicAnimation? = nil
    var priorPath: CGPath? = nil
    
    // CAAction Protocol
    func run(forKey event: String, object anObject: Any, arguments dict: [AnyHashable : Any]?) {
        guard let layer = anObject as? CALayer, let animation = self.pendingAnimation else {
            return
        }
        
        animation.fromValue = self.priorPath
        animation.toValue = layer.shadowPath
        layer.add(animation, forKey: "shadowPath")
    }
} 
Lolalolande answered 24/11, 2017 at 11:9 Comment(1)
Instead of asking people to Please don't upvote this. Upvote Rob Mayoff's solution. you could edit his answer and add the swift code to it! (That would have saved me about 30 minutes of converting it to swift myself before I saw your comment :) ) - regardless, I've up-voted your answer and his.Menell
Q
4

Assuming that you're manually setting your shadowPath, here's a solution inspired by the others here that accomplishes the same thing using less code.

Note that I'm intentionally constructing my own CABasicAnimation rather than copying the bounds.size animation exactly, as in my own tests I found that toggling the copied animation while it was still in progress could cause the animation to snap to it's toValue, rather than transitioning smoothly from its current value.

class ViewWithAutosizedShadowPath: UIView {
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        let oldShadowPath = layer.shadowPath
        let newShadowPath = CGPath(rect: bounds, transform: nil)
        
        if let boundsAnimation = layer.animation(forKey: "bounds.size") as? CABasicAnimation {
            let shadowPathAnimation = CABasicAnimation(keyPath: "shadowPath")

            shadowPathAnimation.duration = boundsAnimation.duration
            shadowPathAnimation.timingFunction = boundsAnimation.timingFunction

            shadowPathAnimation.fromValue = oldShadowPath
            shadowPathAnimation.toValue = newShadowPath
            
            layer.add(shadowPathAnimation, forKey: "shadowPath")
        }
        
        layer.shadowPath = newShadowPath
    }
    
}
Quinacrine answered 7/10, 2020 at 2:2 Comment(3)
Would it make sense to remove the current shadowPath animation before adding the modified one? For example: layer.removeAnimation(forKey: "shadowPath"); layer.add(..)Linkage
IIRC adding a new animation for the same key replaces the old automaticallyQuinacrine
You are correct: developer.apple.com/documentation/quartzcore/calayer/…Linkage
M
0

UITableView is likely not creating a CATransaction, or if it is, it's waiting until after you end the updates. My understanding is that table views just coalesce all changes between those functions and then creates the animations as necessary. You don't have a way to get a handle on the actual animation parameters it's committing, because we don't know when that actually happens. The same thing happens when you animate a content offset change in UIScrollView: the system provides no context about the animation itself, which is frustrating. There is also no way to query the system for current CATransactions.

Probably the best you can do is inspect the animation that UITableView is creating and just mimic the same timing parameters in your own animation. Swizzling add(_:forKey:) on CALayer can allow you to inspect all animations being added. You certainly don't want to actually ship with this, but I often use this technique in debugging to figure out what animations are being added and what their properties are.

I suspect that you're going to have to commit your own shadow layer animations in tableView(_:willDisplayCell:for:row:) for the appropriate cells.

Mainsheet answered 12/5, 2017 at 23:14 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.