Core Animation: Creating a Jack-in-the-box with CAKeyframeAnimation

April 20th, 2008
New iPad puzzle game:
Art Scrambles

jack.png

A previous example demonstrated how CAKeyframeAnimation can move 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-box instead! 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:

  1. The lid of the box opens with enough force that it bounces back, then gradually settles into its open position.
  2. The spring bounces from compressed to full height, then gradually bounces down to its relaxed position.
  3. Jack’s head bounces in tandem with the attached spring.
  4. 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

jack_layers.png

The head sits “on” the spring, so jackLayer lies above springLayer 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 keypath transform.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.

Using an implicit animation to open the lid.
- (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.

lid_diagram.png

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.)

1. Create the keyframe animation to open the lid with a forceful bounce.
// 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.

spring2.png

We’ll apply our explicit animation to the spring’s bounds.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.

2. Create the keyframe animation to make the spring bounce up and down.
// 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.

jacks_head.png

We’ll apply our explicit animation to the spring’s transform.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.)

3. Create the keyframe animation to make Jack’s head wobble.
- (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 and lidLayer, represented here by a Box delegate class) to farthest away (springLayer).

4. Create the layers.
- (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.

5. Open the Jack-in-the-box.
- (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.

6. Close the Jack-in-the-box.
- (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