Core Animation: Creating a Jack-in-the-box with CAKeyframeAnimation
A previous example demonstrated how CAKeyframeAnimation canmove layers along a CGPath, but CAKeyframeAnimation can also move a layer through a succession of points with a custom timing for each point. You might animate a pendulum with two points representing the extreme limits of its swing, for example, easing the pendulum into and out of each swing.
But pendulums are boring—so let’s animate a Jack-in-the-boxinstead! Our Jack-in-the-box will be simple: just a box and lid containing a spring with a clown’s head attached. When you close the box, the spring and head are hidden inside. And when you open the box, the lid flies open and the spring and head bounce out.
These motions require 4 animations:
- The lid of the box opens with enough force that it bounces back, then gradually settles into its open position.
- The spring bounces from compressed to full height, then gradually bounces down to its relaxed position.
- Jack’s head bounces in tandem with the attached spring.
- Jack’s head wobbles exaggeratedly until coming to rest in an upright position.
We’ll use CAKeyframeAnimation for each of the animations.
We’ll represent each piece of the Jack-in-the-box in its own layer:
side → sideLayer
lid → lidLayer
Jack → jackLayer
spring → springLayer
The head sits “on” the spring, so jackLayer
lies abovespringLayer
on the z-axis. And sideLayer
and lidLayer
lie higher still, since when the box is closed, you shouldn’t see the spring and head hidden “inside”.
Opening the lid
When the box opens, the lid swings to an angle of 135°. If that’s all we wanted to do, we could use an implicit animation for the keypathtransform.rotation.z
to swing the lid from closed to open position. You’d see the lid swing smoothly from the closed position to stop at the open position.
- (void)setLidAngleInDegrees:(float)degrees { NSString* keyPath = @"transform.rotation.z"; NSNumber* radians = DegreesToNumber(degrees); [[box lid] setValue:radians forKeyPath:keyPath]; }
But to make the lid opening more interesting, let’s simulate force and tension to make the lid rebound a few times in ever smaller bounces before settling into its resting open position. To keep it simple, each bounce will rise only a third as far as the previous bounce.
Having created the explicit animation with CAKeyframeAnimation, we’ll apply it to the lid’s transform.rotation.z
keypath.
We also need to adjust the lid’s anchor point, since it affects how animations move the layer. Anchor points range from 0
to 1
. The layer’s default anchorPoint
is (0.5,0.5)
, placing it dead-center within the layer, which would cause our animation to pivot the lid on its center point. We want the lid to swing open from its bottom-left corner, so we’ll set the lid’s anchorPoint
to (0,0)
using the Core Graphics constant CGPointZero
. (You’ll see this further below, when we create the layer.)
// keyPath is @"transform.rotation.z" - (CAAnimation*)lidAnimationForKeyPath:(NSString*)keyPath { CAKeyframeAnimation * animation; animation = [CAKeyframeAnimation animationWithKeyPath:keyPath]; animation.duration = [self lidDuration]; animation.delegate = self; animation.removedOnCompletion = NO; animation.fillMode = kCAFillModeForwards; // Create arrays for values and associated timings. float degrees = kLidOpenAngleInDegrees; float delta = kLidOpenAngleInDegrees; NSMutableArray *values = [NSMutableArray array]; NSMutableArray *timings = [NSMutableArray array]; while (delta > 1) { // Bounce back to partially closed position // Starts at closed position, then each bounce is smaller [values addObject:DegreesToNumber(degrees - delta)]; [timings addObject:GetTiming(kCAMediaTimingFunctionEaseIn)]; // Bounce back to fully open position (135°) [values addObject:DegreesToNumber(degrees)]; [timings addObject:GetTiming(kCAMediaTimingFunctionEaseOut)]; // Reduce the size of the bounce by the lid's tension delta *= kLidTension; } animation.values = values; animation.timingFunctions = timings; return animation; }
Bouncing the spring
The spring bouncing animation follows a similar pattern, bouncing from its compressed height to its extended height. But unlike the lid, the spring doesn’t come to rest at its extended height. Instead, it settles at its “relaxed” height, which lies somewhere between compressed and extended. We can simulate force and tension here just as we did for the lid by making each bounce a fixed percentage of the previous bounce.
We’ll apply our explicit animation to the spring’sbounds.size.height
keypath.
And because the head and spring bounce in tandem, we can use the same animation to bounce Jack’s head by applying the animation to the head’s position.y
keyPath. Each bounce then stretches the spring and moves Jack’s head by the exact same distance, preserving the illusion that they’re attached.
// keyPath for the spring is @"bounds.size.height" // keyPath for Jack's head is @"position.y" - (CAAnimation*)bounceAnimationForKeyPath:(NSString*)keyPath heightAtRest:(float)heightAtRest { CAKeyframeAnimation * animation; animation = [CAKeyframeAnimation animationWithKeyPath:keyPath]; animation.duration = [self springDuration]; animation.delegate = self; animation.removedOnCompletion = NO; animation.fillMode = kCAFillModeForwards; animation.beginTime = CACurrentMediaTime () + [self initialDelay]; // Create arrays for values and associated timings. NSMutableArray *values = [NSMutableArray array]; NSMutableArray *timings = [NSMutableArray array]; float bounceHeight = kSpringBounceHeight; while (bounceHeight > 1) { // Bounce up float bounceTop = heightAtRest + bounceHeight; [values addObject:[NSNumber numberWithFloat:bounceTop]]; [timings addObject:GetTiming(kCAMediaTimingFunctionEaseOut)]; // Return to rest [values addObject:[NSNumber numberWithFloat:heightAtRest]]; [timings addObject:GetTiming(kCAMediaTimingFunctionEaseIn)]; // Reduce the height of the bounce by the spring's tension bounceHeight *= kSpringTension; } animation.values = values; animation.timingFunctions = timings; return animation; }
Wobbling Jack’s head
The animation used to wobble Jack’s head back and forth is almost identical to those used to animate the lid and spring. It bobs Jack’s head left then right with decreasing force.
We’ll apply our explicit animation to the spring’stransform.rotation.z
keypath. But unlike the lid, which pivots on its bottom-left corner, Jack’s head pivots on its bottom-center point, so we’ll need to set its anchorPoint
to(0.5, 0)
. (You’ll see this further below, when we create the layer.)
- (CAAnimation*)wobbleAnimationForKeyPath:(NSString*)keyPath { CAKeyframeAnimation * animation; animation = [CAKeyframeAnimation animationWithKeyPath:keyPath]; animation.duration = [self wobbleDuration]; animation.delegate = self; animation.removedOnCompletion = YES; animation.fillMode = kCAFillModeForwards; animation.beginTime = CACurrentMediaTime () + [self initialDelay]; // Create arrays for values and associated timings. NSMutableArray *values = [NSMutableArray array]; NSMutableArray *timings = [NSMutableArray array]; float wobbleDegrees = kWobbleDegrees; while (wobbleDegrees > 1) { // wobble left [values addObject:DegreesToNumber(wobbleDegrees)]; [timings addObject:GetTiming(kCAMediaTimingFunctionEaseOut)]; // wobble right [values addObject:DegreesToNumber(-wobbleDegrees)]; [timings addObject:GetTiming(kCAMediaTimingFunctionEaseOut)]; // Reduce the distance of the wobble by the head's tension wobbleDegrees *= kWobbleTension; } animation.values = values; animation.timingFunctions = timings; return animation; }
Putting it all together
Now we’ll create the layers, then apply the animations we’ve designed to the layers to make the Jack-in-the-box open and close. The layers are created from nearest (sideLayer
andlidLayer
, represented here by a Box delegate class) to farthest away (springLayer
).
- (void)createLayers:(CALayer *)parentLayer { // Create the box CGRect boxRect = CGRectMake(75, 200, 200, 200); box = [[Box alloc] initWithRect:boxRect inLayer:parentLayer]; // It's not shown here, but Box creates a layer each for the // box's side and lid. The lid's anchorPoint is set to // CGPointZero to ensure that the lid swings open from its // bottom-left corner. // Create Jack's head CGPoint jackPos = CGPointMake(100, kHeadYPosition - [box lidHeight]); jackLayer = [self makeJack:jackPos withSize:CGSizeMake(150, 150)]; // Use a delegate to draw Jack's head jackLayer.delegate = [[Jack alloc] init]; // Center the head's anchorPoint horizontally because it // wobbles back and forth based on that point. jackLayer.anchorPoint = CGPointMake(0.5, 0); // Move the spring slightly back to hide it behind the box. NSNumber* jackZPos = [NSNumber numberWithFloat:-1]; [jackLayer setValue:jackZPos forKeyPath:@"zPosition"]; [jackLayer setNeedsDisplay]; [parentLayer addSublayer: jackLayer]; // Create the spring CGPoint springPos = CGPointMake(125, 205); CGSize springSize = CGSizeMake(100, 600); springLayer = [self makeSpring:springPos withSize:springSize]; // Use a delegate to draw the spring springLayer.delegate = [[Spring alloc] init]; // Move the spring slightly back to hide it // behind the box and head. NSNumber* springZPos = [NSNumber numberWithFloat:-2]; [springLayer setValue:springZPos forKeyPath:@"zPosition"]; [springLayer setNeedsDisplay]; [parentLayer addSublayer: springLayer]; }
To open the Jack-in-the-box, we create the 4 explicit CAKeyframeAnimation animations we need, then apply them to the layers.
- (void)openJack { // Animate the lid. NSString* keyPath = @"transform.rotation.z"; CAAnimation* lidOpening; lidOpening = [self lidAnimationForKeyPath:keyPath]; [[box lid] addAnimation:lidOpening forKey:keyPath]; // Animate the spring. keyPath = @"bounds.size.height"; CAAnimation* springBounce; springBounce = [self bounceAnimationForKeyPath:keyPath heightAtRest:kSpringRestingHeight]; [springLayer addAnimation:springBounce forKey:keyPath]; // Animate Jack's head. keyPath = @"position.y"; CAAnimation* jackBounce; jackBounce = [self bounceAnimationForKeyPath:keyPath heightAtRest:kSpringHeight]; [jackLayer addAnimation:jackBounce forKey:keyPath]; keyPath = @"transform.rotation.z"; CAAnimation* jackWobble; jackWobble = [self wobbleAnimationForKeyPath:keyPath]; [jackLayer addAnimation:jackWobble forKey:keyPath]; }
To close the Jack-in-the-box, we remove all existing animations, then apply implicit animations to lid, head, and box to restore them to their closed positions.
- (void)closeJack { // Clear all previous animations. [[box lid] removeAllAnimations]; [jackLayer removeAllAnimations]; [springLayer removeAllAnimations]; // Close the lid with a simple implicit animation [self setLidAngleInDegrees:0]; // Recompress spring to its minimal height NSNumber* compressedHeight; compressedHeight = [NSNumber numberWithFloat:200]; [springLayer setValue:compressedHeight forKeyPath:@"bounds.size.height"]; // Reset Jack's head original y pos atop compressed spring NSNumber* compressedPos = [NSNumber numberWithFloat:200]; [jackLayer setValue:compressedPos forKeyPath:@"position.y"]; }
Download and try it
Download (requires Leopard): application | source code