Core Animation Target-Action Layers
When you write a Core Animation heavy application, you spend a lot of time implementing code that executes when an animation finishes. Typically, the view controller whose view contains the animating layers implements animationDidStop:finished: and does a series of checks to see which animation finished. This method becomes difficult to manage as the number of animations it handles increases. You must also set the delegate of each animation object and tell them not to remove themselves when finished. You end up writing a lot of code over and over again.
It would be simpler to have animations work more like UIControls. A layer would have a set of target-action pairs that would be triggered when an animation it is running completes. That way, you could easily chain animations or have code executed only after an animation completes.
How would you achieve this functionality? You would subclass CALayer. This layer subclass would have a list of target-action pairs for animation keys. (Note: Not animation key paths, but rather the name you assign to an animation when it is added to a layer.) Here is the code for that subclass:
BNRActionLayer.h:
#import <Foundation/Foundation.h>
#import <QuartzCore/QuartzCore.h>
@interface BNRActionLayer : CALayer {
NSMutableDictionary *targetActionPairs;
}
- (void)addTarget:(id)t action:(SEL)a forKey:(NSString *)k;
- (void)removeTarget:(id)t action:(SEL)a forKey:(NSString *)k;
- (NSArray *)actionsForTarget:(id)t forKey:(NSString *)k;
@end
BNRActionLayer.m:
#import "BNRActionLayer.h"
// Declare a private class to keep track of target-action pairs
@interface BNRActionLayerTargetActionPair : NSObject
{
id target;
SEL action;
}
@property (nonatomic, assign) id target;
@property (nonatomic, assign) SEL action;
@end
@implementation BNRActionLayerTargetActionPair
@synthesize target, action;
@end
@interface BNRActionLayer (Private)
- (NSMutableArray *)pairsForKey:(NSString *)k;
@end
@implementation BNRActionLayer
- (void)animationDidStop:(CAAnimation *)theAnimation finished:(BOOL)flag
{
for(NSString *observedKey in targetActionPairs) {
if([self animationForKey:observedKey] == theAnimation) {
NSArray *pairs = [self pairsForKey:observedKey];
[self removeAnimationForKey:observedKey];
for(BNRActionLayerTargetActionPair *pair in pairs) {
if(flag)
[[pair target] performSelector:[pair action]
withObject:self];
}
}
}
}
- (void)addAnimation:(CAAnimation *)theAnimation forKey:(NSString *)key
{
NSArray *targetActionsForThisKey = [targetActionPairs objectForKey:key];
if([targetActionsForThisKey count] > 0) {
[theAnimation setRemovedOnCompletion:NO];
[theAnimation setDelegate:self];
}
[super addAnimation:theAnimation forKey:key];
}
- (void)addTarget:(id)t action:(SEL)a forKey:(NSString *)k
{
if(!targetActionPairs)
targetActionPairs = [[NSMutableDictionary alloc] init];
NSMutableArray *pairsForKey = [self pairsForKey:k];
if(!pairsForKey) {
pairsForKey = [NSMutableArray array];
[targetActionPairs setObject:pairsForKey forKey:k];
}
for(BNRActionLayerTargetActionPair *pair in pairsForKey) {
if([pair target] == t && [pair action] == a)
return;
}
BNRActionLayerTargetActionPair *newPair =
[[BNRActionLayerTargetActionPair alloc] init];
[newPair setTarget:t];
[newPair setAction:a];
[pairsForKey addObject:newPair];
[newPair release];
}
- (void)removeTarget:(id)t action:(SEL)a forKey:(NSString *)k
{
NSMutableArray *pairsForKey = [self pairsForKey:k];
if(!pairsForKey)
return;
BNRActionLayerTargetActionPair *removablePair = nil;
for(BNRActionLayerTargetActionPair *pair in pairsForKey) {
if([pair target] == t && [pair action] == a) {
removablePair = pair;
break;
}
}
[pairsForKey removeObject:removablePair];
}
- (NSMutableArray *)pairsForKey:(NSString *)k
{
return [targetActionPairs objectForKey:k];
}
- (NSArray *)actionsForTarget:(id)t forKey:(NSString *)k
{
NSMutableArray *list = [NSMutableArray array];
NSMutableArray *pairsForKey = [self pairsForKey:k];
for(BNRActionLayerTargetActionPair *pair in pairsForKey) {
if([pair target] == t)
[list addObject:NSStringFromSelector([pair action])];
}
return [NSArray arrayWithArray:list];
}
- (void)dealloc
{
[targetActionPairs release];
[super dealloc];
}
@end
How do you use this class? In your view subclass, make sure the type of layer it uses is of type BNRActionLayer (if you are using an explicit layer, you would simply create an instance of BNRActionLayer):
@implementation MyView
+ (Class)layerClass
{
return [BNRActionLayer class];
}
@end
When you create an instance of MyView, you can add target-action pairs to it.
- (void)applicationDidFinishLaunching:(UIApplication *)app
{
MyView *v = [[[MyView alloc] initWithFrame:someRect] autorelease];
[window addSubview:v];
[(BNRActionLayer *)[v layer] addTarget:self
action:@selector(viewDidFadeIn:)
forKey:@"Fade In"];
[window makeKeyAndVisible];
}
Of course, you then need to implement viewDidFadeIn: to do something. Let’s pretend you are fading a view so it can become touchable:
- (void)viewDidFadeIn:(BNRActionLayer *)layer
{
// We can operate on the layer here, do some controllery stuff, and we
// can also get the this layers owning view. An implicit layer's delegate
// is always its view (on the iPhone).
MyView *v = [layer delegate];
[v setUserInteractionEnabled:YES];
}
So, how would you create an animation that will trigger this message to be sent when it finishes? The same way as you would normally create an animation:
- (void)activateView:(MyView *)v
{
CABasicAnimation *a = [CABasicAnimation animationWithKeyPath:@"opacity"];
[a setToValue:[NSNumber numberWithFloat:1]];
[a setDuration:1];
// The key here matches the key the target-action pair was added for
[[v layer] addAnimation:a forKey:@"Fade In"];
}
Cool, huh? Disclaimer: I’ve been using this code for a few days and it hasn’t given me any problems. However, that doesn’t mean it is perfect. This was definitely a wake-up-at-3am-from-a-programming-dream-and-write-code type deal. (I dream about programming. I’m weird.) If you have any suggestions for improving the code or find a problem, by all means, please don’t hesitate to comment.
Furthermore, if your understanding of layers is a bit fuzzy (for example, you did a double-take when I said that a layer’s delegate is always its view), be sure to keep an eye out for the Big Nerd Ranch iPhone book, written by Aaron Hillegass and myself, due early next year.
Posted by Joe Conway on December 8th, 2009 under iPhone.
Comments: 5
Comments
Comment from Steven Degutis
Time: December 9, 2009, 10:07 am
Incredibly clever solution, thanks Joe! I’m sure it’ll come in handy. The next fun challenge is to port this to the desktop so it works when using -animator in non-layer-backed views
Comment from Jeremy W. Sherman
Time: December 17, 2009, 9:44 pm
I like this. It reminds me of MAKVONotificationCenter’s approach to shifting the humongous mess that -observeValueForKeyPath:ofObject:change:context: becomes into a target-action system like NSNotificationCenter uses.
The one nit I have is that it seems -addTarget:action:forKey: must be called prior to -addAnimation:forKey:, otherwise the animation will not be configured by -addAnimation:forKey:.
Replacing the separate methods with a single method named -addAnimation:forKey:didStopTarget:action: would ensure that all animations with a target-action would be properly configured. It would also be an orthogonal extension to CALayer that wouldn’t require subclassing. You could move it into its own class that composes a CALayer. -addAnimationDidStopTarget:action:forKey: could be supported but defined to throw an exception if no animation has been set for that key, or an animation has been set but not properly configured. This would make scheduling a callback for a misconfigured or absent animation a programmer error (sadly discovered only at runtime).
You could turn that method into a mix-in using either #include without include guards or a less heavy-handed approach like RXConcreteProtocol (http://github.com/robrix/RXConcreteProtocol).
My experience with CALayers is similar to my experience with Tibetan throat-singing, though, so I’m unaware of any implicit state changes that would affect my evaluation.
Comment from Joe Conway
Time: December 18, 2009, 3:41 pm
True, I thought about the problem of target-action pairs not existing before addAnimation:forKey: is called. But, this behavior is somewhat similar to UIControl: if you tap a button before it has a target-action pair, nothing happens. Also, this approach allows you to reuse target-action pairs across multiple animations without having to type in the target and action each time.
But, I do think having both a options would be worthwhile (adding target-action pairs independently or on a per-animation basis).
Comment from Joe Conway
Time: December 18, 2009, 3:43 pm
Also of note, it would be nice to use nil-targeted actions. However, sending an action method to UIApplication requires a UIEvent object – I am not sure how well creating an empty UIEvent object or passing nil would work in that situation. Even if it tested okay now, you never know what might happen in future OS releases.
Comment from StefanB
Time: January 27, 2010, 6:00 pm
“keep an eye out for the Big Nerd Ranch iPhone book”, I hope it will be in the same style as Cocoa Programming for Mac OS X. Can’t wait to buy it.
Great article I’ve just been tackling layers and animation (from Cocoa Prog. for Mac OS X).
Btw off topic, when I viewed this blog via iPhone the source code part got clipped off (no word wrap). Probably some html tweak needed?
Code syntax highlight? I just figured out how to do it in python (see my blog if interested)
Write a comment