iOS Core Animation: Advanced Techniques (2014)

Part II. Setting Things in Motion

Chapter 9. Layer Time

The biggest difference between time and space is that you can’t reuse time.

Merrick Furst

In the previous two chapters, we explored the various types of layer animation that can be implemented using CAAnimation and its subclasses. Animation is a change that happens over time, so timing is crucial to the whole concept. In this chapter, we will look at the CAMediaTimingprotocol, which is how Core Animation keeps track of time.

The CAMediaTiming Protocol

The CAMediaTiming protocol defines a collection of properties that are used to control the passage of time during an animation. Both CALayer and CAAnimation conform to this protocol, so time can be controlled on both a per-layer and per-animation basis.

Duration and Repetition

We briefly mentioned duration (one of the CAMediaTiming properties) in Chapter 8, “Explicit Animations.” The duration property is of type CFTimeInterval (which is a double-precision floating-point value that represents seconds, just like NSTimeInterval), and it is used to specify the duration for which a single iteration of an animation will run.

What do we mean by a single iteration? Well, another property of CAMediaTiming is repeatCount, which determines the number of iterations that an animation will be repeated for. The value of repeatCount represents the total number of times the animation will be played. If theduration is 2 seconds, and repeatCount is set to 3.5 (three-and-a-half iterations), the total time spent animating will be 7 seconds.

The duration and repeatCount properties both default to zero. This doesn’t mean that the animation has a duration of zero seconds, or repeats zero times; a value of zero in this case, is just used to mean “use the defaults,” which are 0.25 seconds and one iteration, respectively. You can try out various values for these properties using the simple test program in Listing 9.1Figure 9.1 shows the program interface.

Listing 9.1 Testing the duration and repeatCount Properties


@interface ViewController ()

@property (nonatomic, weak) IBOutlet UIView *containerView;
@property (nonatomic, weak) IBOutlet UITextField *durationField;
@property (nonatomic, weak) IBOutlet UITextField *repeatField;
@property (nonatomic, weak) IBOutlet UIButton *startButton;
@property (nonatomic, strong) CALayer *shipLayer;

@end

@implementation ViewController

- (void)viewDidLoad
{
    [super viewDidLoad];

    //add the ship
    self.shipLayer = [CALayer layer];
    self.shipLayer.frame = CGRectMake(0, 0, 128, 128);
    self.shipLayer.position = CGPointMake(150, 150);
    self.shipLayer.contents = (__bridge id)[UIImage imageNamed:
                                            @"Ship.png"].CGImage;
    [self.containerView.layer addSublayer:self.shipLayer];
}

- (void)setControlsEnabled:(BOOL)enabled
{
    for (UIControl *control in @[self.durationField,
         self.repeatField, self.startButton])
    {
        control.enabled = enabled;
        control.alpha = enabled? 1.0f: 0.25f;
    }
}

- (IBAction)hideKeyboard
{
    [self.durationField resignFirstResponder];
    [self.repeatField resignFirstResponder];
}

- (IBAction)start
{
    CFTimeInterval duration = [self.durationField.text doubleValue];
    float repeatCount = [self.repeatField.text floatValue];

    //animate the ship rotation
    CABasicAnimation *animation = [CABasicAnimation animation];
    animation.keyPath = @"transform.rotation";
    animation.duration = duration;
    animation.repeatCount = repeatCount;
    animation.byValue = @(M_PI * 2);
    animation.delegate = self;
    [self.shipLayer addAnimation:animation forKey:@"rotateAnimation"];

    //disable controls
    [self setControlsEnabled:NO];
}

- (void)animationDidStop:(CAAnimation *)anim finished:(BOOL)flag
{
    //reenable controls
    [self setControlsEnabled:YES];
}

@end


Image

Figure 9.1 A test program to demonstrate the duration and repeatCount properties

An alternative way to create a repeating animation is to use the repeatDuration property, which tells the animation to repeat for a fixed time period instead of for a fixed number of iterations. You can even set a property called autoreverses (of type BOOL) to make the animation play backward during each alternate cycle. This is great for playing a nonlooping animation continuously, such as a door swinging open and then shut (see Figure 9.2).

Image

Figure 9.2 A swinging door animation

The code for the swinging door is shown in Listing 9.2. We’ve simply animated the outward swing and used the autoreverses property to make the door swing back automatically. In this case, we’ve set repeatDuration to INFINITY so that the animation plays indefinitely, although setting repeatCount to INFINITY would have had the same effect. Note that the repeatCount and repeatDuration properties could potentially contradict each other, so you should only specify a nonzero value for either one or the other. The behavior if both properties are nonzero is undefined.

Listing 9.2 Swinging Door Implemented Using the autoreverses Property


@interface ViewController ()

@property (nonatomic, weak) UIView *containerView;

@end

@implementation ViewController

- (void)viewDidLoad
{
    [super viewDidLoad];

    //add the door
    CALayer *doorLayer = [CALayer layer];
    doorLayer.frame = CGRectMake(0, 0, 128, 256);
    doorLayer.position = CGPointMake(150 - 64, 150);
    doorLayer.anchorPoint = CGPointMake(0, 0.5);
    doorLayer.contents = (__bridge id)[UIImage imageNamed:
                                       @"Door.png"].CGImage;
    [self.containerView.layer addSublayer:doorLayer];

    //apply perspective transform
    CATransform3D perspective = CATransform3DIdentity;
    perspective.m34 = -1.0 / 500.0;
    self.containerView.layer.sublayerTransform = perspective;

    //apply swinging animation
    CABasicAnimation *animation = [CABasicAnimation animation];
    animation.keyPath = @"transform.rotation.y";
    animation.toValue = @(-M_PI_2);
    animation.duration = 2.0;
    animation.repeatDuration = INFINITY;
    animation.autoreverses = YES;
    [doorLayer addAnimation:animation forKey:nil];
}

@end


Relative Time

As far as Core Animation is concerned, time is relative. Each animation has its own representation of time, which can be independently sped up, delayed, or offset.

The beginTime property specifies the time delay before the animation begins. This delay is measured from the point at which the animation is added to a visible layer. This defaults to zero (that is, the animation will begin immediately).

The speed property is a time multiplier. It defaults to 1.0, but decreasing it will slow down time for the layer/animation and increasing it will speed up time. With a speed of 2.0, an animation with a nominal duration of 1 second will actually complete in 0.5 seconds.

The timeOffset property is similar to beginTime in that it time-shifts the animation. But while increasing the beginTime increases the delay before an animation begins, increasing the timeOffset fast-forwards to a specific point in the animation. For example, for an animation that lasts 1 second, setting a timeOffset of 0.5 seconds would mean that the animation starts halfway through.

Unlike beginTime, timeOffset is unaffected by speed. So, if you were to increase the speed to 2.0 as well as setting the timeOffset to 0.5, you would have effectively skipped to the end of the animation because a 1-second animation sped up by a factor of two lasts for only 0.5 seconds. However, even if you skip to the end of the animation using the timeOffset, it will still play for the same total duration; the animation simply loops around and plays again up until the point where it originally started.

You can try this out with the simple test app in Listing 9.3. Just set the speed and timeOffset sliders to the desired value, and then press Play to see the effect they have (see Figure 9.3).

Listing 9.3 Testing the timeOffset and speed Properties


@interface ViewController ()

@property (nonatomic, weak) IBOutlet UIView *containerView;
@property (nonatomic, weak) IBOutlet UILabel *speedLabel;
@property (nonatomic, weak) IBOutlet UILabel *timeOffsetLabel;
@property (nonatomic, weak) IBOutlet UISlider *speedSlider;
@property (nonatomic, weak) IBOutlet UISlider *timeOffsetSlider;
@property (nonatomic, strong) UIBezierPath *bezierPath;
@property (nonatomic, strong) CALayer *shipLayer;

@end

@implementation ViewController

- (void)viewDidLoad
{
    [super viewDidLoad];

    //create a path
    self.bezierPath = [[UIBezierPath alloc] init];
    [self.bezierPath moveToPoint:CGPointMake(0, 150)];
    [self.bezierPath addCurveToPoint:CGPointMake(300, 150)
                       controlPoint1:CGPointMake(75, 0)
                       controlPoint2:CGPointMake(225, 300)];

    //draw the path using a CAShapeLayer
    CAShapeLayer *pathLayer = [CAShapeLayer layer];
    pathLayer.path = self.bezierPath.CGPath;
    pathLayer.fillColor = [UIColor clearColor].CGColor;
    pathLayer.strokeColor = [UIColor redColor].CGColor;
    pathLayer.lineWidth = 3.0f;
    [self.containerView.layer addSublayer:pathLayer];

    //add the ship
    self.shipLayer = [CALayer layer];
    self.shipLayer.frame = CGRectMake(0, 0, 64, 64);
    self.shipLayer.position = CGPointMake(0, 150);
    self.shipLayer.contents = (__bridge id)[UIImage imageNamed:
                                            @"Ship.png"].CGImage;
    [self.containerView.layer addSublayer:self.shipLayer];

    //set initial values
    [self updateSliders];
}

- (IBAction)updateSliders
{
    CFTimeInterval timeOffset = self.timeOffsetSlider.value;
    self.timeOffsetLabel.text = [NSString stringWithFormat:@"%0.2f",
                                 timeOffset];

    float speed = self.speedSlider.value;
    self.speedLabel.text = [NSString stringWithFormat:@"%0.2f", speed];
}

- (IBAction)play
{
    //create the keyframe animation
    CAKeyframeAnimation *animation = [CAKeyframeAnimation animation];
    animation.keyPath = @"position";
    animation.timeOffset = self.timeOffsetSlider.value;
    animation.speed = self.speedSlider.value;
    animation.duration = 1.0;
    animation.path = self.bezierPath.CGPath;
    animation.rotationMode = kCAAnimationRotateAuto;
    animation.removedOnCompletion = NO;
    [self.shipLayer addAnimation:animation forKey:@"slide"];
}

@end


Image

Figure 9.3 A simple app to test the effect of time offset and speed

fillMode

An animation that has a beginTime greater than zero can be in a state where it is attached to a layer but has not yet started animating. Similarly, an animation whose removeOnCompletion property is set to NO will remain attached to a layer after it has finished. This raises the question of what the value of the animated properties will be before the animation has started and after it has ended.

One possibility is that the property values could be the same as if the animation was not attached at all, that is, the value would be whatever was defined in the model layer. (See Chapter 7, “Implicit Animations,” for an explanation of layer model versus presentation.)

Another option is that the properties could take on the value of the first frame of the animation prior to it beginning and retain the last frame after it has ended. This is known as filling, because the animation’s start and end values are used to fill the time before and after the animation’s duration.

This behavior is left up to the developer; it can be controlled using the fillMode property of CAMediaTiming. fillMode is an NSString and accepts one of the following constant values:

kCAFillModeForwards
kCAFillModeBackwards
kCAFillModeBoth
kCAFillModeRemoved

The default is kCAFillModeRemoved, which sets the property values to whatever the layer model specifies when the animation isn’t currently playing. The other three modes fill the animation forward, backward, or both so that the animatable properties take on the start value specified in the animation before it begins and/or the end value once it has finished.

This provides an alternative solution to the problem of having to manually update a layer property to match the end value of an animation to avoid snap-back when the animation finishes (mentioned in Chapter 8). Bear in mind, however, that if you intend to use it for this purpose, you will need to set removeOnCompletion to NO for your animation. You will also need to use a non-nil key when adding the animation, so that you can manually remove it from the layer when you no longer need it.

Hierarchical Time

In Chapter 3, “Layer Geometry,” you learned how each layer has a spatial coordinate system that is defined relative to its parent in the layer tree. Animation timing works in a similar way. Each animation and layer has its own hierarchical concept of time, measured relative to its parent. Adjusting the timing for a layer will affect its own animations and those of its sublayers, but not its superlayer. The same goes for animations that are grouped hierarchically (using nested CAAnimationGroup instances).

Adjusting the duration and repeatCount/repeatDuration properties for a CALayer or CAGroupAnimation will not affect its children’s animations. The beginTime, timeOffset, and speed properties will impact child animations, however. In hierarchical terms,beginTime specifies a time offset between when the parent layer (or parent animation in the case of a grouped animation) starts animating and when the object in question should begin its own animation. Similarly, adjusting the speed property of a CALayer or CAGroupAnimationwill apply a scaling factor to the animation speed for all of the children, as well.

Global Versus Local Time

Core Animation has a concept of global time, also known as mach time (“mach” being the name for the system kernel on iOS and Mac OS). Mach time is global only in the sense that it is the same across all processes on the device—it isn’t universal across different devices—but that is sufficient to make it useful as a reference point for animations. To access mach time, you can use the CACurrentMediaTime function, as follows:

CFTimeInterval time = CACurrentMediaTime();

The absolute value returned by this function is largely irrelevant (it reflects the number of seconds that the device has been awake since it was last rebooted, which is unlikely to be something you care about), but its purpose is to act as a relative value against which you can make timing measurements. Note that mach time pauses when the device is asleep, which means that all CAAnimations (which depend on mach time) will also pause.

For this reason, mach time is not useful for making long-term time measurements. It would be unwise to use CACurrentMediaTime to update a real-time clock, for example. (You can poll the current value of [NSDate date] for that purpose instead, as we did in our clock example inChapter 3.)

Each CALayer and CAAnimation instance has its own local concept of time, which may differ from global time depending on the beginTime, timeOffset, and speed properties of the parent objects in the layer/animation hierarchy. Just as there are methods to convert between different layers’ local spatial coordinate systems, CALayer also has methods to convert between different layers’ local time frames. These are as follows:

- (CFTimeInterval)convertTime:(CFTimeInterval)t fromLayer:(CALayer *)l;
- (CFTimeInterval)convertTime:(CFTimeInterval)t toLayer:(CALayer *)l;

These methods can be useful if you are trying to synchronize animations between multiple layers that do not share the same speed, or have nonzero timeOffset or beginTime values.

Pause, Rewind, and Fast-Forward

Setting an animation’s speed property to zero will pause it, but it’s not actually possible to modify an animation after it has been added to a layer, so you can’t use this property to pause an animation that’s in progress. Adding a CAAnimation to a layer makes an immutable copy of the animation object; so changing the properties of the original animation has no effect on the one actually attached to the layer. Conversely, retrieving the in-progress animation directly from the layer by using -animationForKey: will give you the correct animation object, but attempting to modify its properties will throw an exception.

If you remove an in-progress animation from its layer, the layer will snap back to its pre-animated state. But if you copy the property values from the presentation layer to the model layer before removing the animation, the animation will appear to have paused. The disadvantage of this is that you cannot easily resume the animation again later.

A simpler and less-destructive approach is to make use of the hierarchical nature of CAMediaTiming and pause the layer itself. If you set the layer speed to zero, it will pause any animations that are attached to that layer. Similarly, setting speed to a value greater than 1.0 will “fast-forward,” and setting a negative speed will “rewind” the animation.

By increasing the layer speed for the main window in your app, you can actually speed up animations across the entire application. This can prove useful for something like UI automation, where the tests can be made to run faster if you speed up all the view transitions. (Note that views that are displayed outside of the main window—such as UIAlertView—will not be affected.) Try adding the following line to your app delegate to see this in action:

self.window.layer.speed = 100;

You can also slow down animations across the app in this way, but this is less useful since it’s already possible to slow down animations in the iOS Simulator by using the Toggle Slow Animations option in the Debug menu.

Manual Animation

A really interesting feature of the timeOffset property is that it enables you to manually scrub through an animation. By setting speed to zero, you can disable the automatic playback of an animation and then use the timeOffset to move back and forth through the animation sequence. This can be a nifty way to allow the user to manipulate an animated user interface element using gestures.

We’ll try a simple example first: Starting with the swinging door animation from earlier in the chapter, let’s modify the code so that the animation is controlled using a finger gesture. We do this by attaching a UIPanGestureRecognizer to our view and then using it to set thetimeOffset by swiping left and right.

Because we cannot modify the animation after it has been added to the layer, what we will do instead is pause and adjust the timeOffset value for the layer, which in this case has the same effect as if we were manipulating the animation directly (see Listing 9.4).

Listing 9.4 Manually Driving an Animation Using Touch Gestures


@interface ViewController ()

@property (nonatomic, weak) UIView *containerView;
@property (nonatomic, strong) CALayer *doorLayer;

@end

@implementation ViewController

- (void)viewDidLoad
{
    [super viewDidLoad];

    //add the door
    self.doorLayer = [CALayer layer];
    self.doorLayer.frame = CGRectMake(0, 0, 128, 256);
    self.doorLayer.position = CGPointMake(150 - 64, 150);
    self.doorLayer.anchorPoint = CGPointMake(0, 0.5);
    self.doorLayer.contents =
        (__bridge id)[UIImage imageNamed:@"Door.png"].CGImage;
    [self.containerView.layer addSublayer:self.doorLayer];

    //apply perspective transform
    CATransform3D perspective = CATransform3DIdentity;
    perspective.m34 = -1.0 / 500.0;
    self.containerView.layer.sublayerTransform = perspective;

    //add pan gesture recognizer to handle swipes
    UIPanGestureRecognizer *pan = [[UIPanGestureRecognizer alloc] init];
    [pan addTarget:self action:@selector(pan:)];
    [self.view addGestureRecognizer:pan];

    //pause all layer animations
    self.doorLayer.speed = 0.0;

    //apply swinging animation (which won't play because layer is paused)
    CABasicAnimation *animation = [CABasicAnimation animation];
    animation.keyPath = @"transform.rotation.y";
    animation.toValue = @(-M_PI_2);
    animation.duration = 1.0;
    [self.doorLayer addAnimation:animation forKey:nil];
}

- (void)pan:(UIPanGestureRecognizer *)pan
{
    //get horizontal component of pan gesture
    CGFloat x = [pan translationInView:self.view].x;

    //convert from points to animation duration
    //using a reasonable scale factor
    x /= 200.0f;

    //update timeOffset and clamp result
    CFTimeInterval timeOffset = self.doorLayer.timeOffset;
    timeOffset = MIN(0.999, MAX(0.0, timeOffset - x));
    self.doorLayer.timeOffset = timeOffset;

    //reset pan gesture
    [pan setTranslation:CGPointZero inView:self.view];
}

@end


That’s a neat trick, but you might be thinking that it would be easier to just set the door’s transform directly using our pan gesture, rather than setting up an animation and then only displaying a single frame at a time.

That is true in this simple case, but for a more complex case such as a keyframe animation, or an animation group with several moving layers, this is actually a very simple way to scrub through the animation without having to manually calculate each property of every layer at a given point in time.

Summary

In this chapter, you learned about the CAMediaTiming protocol and the mechanisms that Core Animation uses to manipulate time for the purposes of controlling animations. In the next chapter, we cover easing, another time-manipulation technique used to make animations appear more natural.