8. Explicit animation
Explicit animation
If you want things to go smoothly, you have to rely on yourself
The previous chapter introduced the concept of implicit animation. Implicit animation is a direct way to create dynamic user interface on iOS platform, and it is also the basis of UIKit animation mechanism, but it can not cover all types of animation. In this chapter, we will study explicit animation, which can customize some attributes or create non-linear animation, such as moving along any curve.
8.1 attribute animation
Attribute animation
CAAnimationDelegate cannot be found in any header file, but related functions can be found in the CAAnimation header file or in the apple developer documentation. In this example, we use the - animationDidStop:finished: method to update the backgroundColor of the layer after the end of the animation.
When updating properties, we need to set a new transaction and disable layer behavior. Otherwise, the animation will occur twice, one is because of the explicit cabasic animation, and the other is because of the implicit animation. See order 8.3 for the specific implementation.
A developer, with a learning atmosphere and a communication circle is particularly important. This is my iOS communication group: 1012951431, sharing BAT, Ali interview questions, interview experience, discussing technology, and sharing learning and growth together! Hope to help developers avoid detours.
Listing 8.3 changing the background color of the layer after the animation is complete
@implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; //create sublayer self.colorLayer = [CALayer layer]; self.colorLayer.frame = CGRectMake(50.0f, 50.0f, 100.0f, 100.0f); self.colorLayer.backgroundColor = [UIColor blueColor].CGColor; //add it to our view [self.layerView.layer addSublayer:self.colorLayer]; } - (IBAction)changeColor { //create a new random color CGFloat red = arc4random() / (CGFloat)INT_MAX; CGFloat green = arc4random() / (CGFloat)INT_MAX; CGFloat blue = arc4random() / (CGFloat)INT_MAX; UIColor *color = [UIColor colorWithRed:red green:green blue:blue alpha:1.0]; //create a basic animation CABasicAnimation *animation = [CABasicAnimation animation]; animation.keyPath = @"backgroundColor"; animation.toValue = (__bridge id)color.CGColor; animation.delegate = self; //apply animation to layer [self.colorLayer addAnimation:animation forKey:nil]; } - (void)animationDidStop:(CABasicAnimation *)anim finished:(BOOL)flag { //set the backgroundColor property to match animation toValue [CATransaction begin]; [CATransaction setDisableActions:YES]; self.colorLayer.backgroundColor = (__bridge CGColorRef)anim.toValue; [CATransaction commit]; } @end
For CAAnimation, using delegate mode instead of a completion block can cause the problem that when you have multiple animations, you can't distinguish them in callback methods. When you create an animation in a view controller, you usually use the controller itself as a delegate (as shown in listing 8.3), but all animations call the same callback method, so you need to determine which layer is calling.
Consider the alarm clock in Chapter 3, "layer geometry". We can simply update the angle of the pointer every second to achieve a clock, but it will be more realistic if the pointer dynamically turns to a new position.
We can't use implicit animation because these pointers are instances of UIView, so the implicit animation of layers is disabled. We can simply use UIView's animation method to achieve this. But if you want to control the animation time better, you can use explicit animation better (see Chapter 10 for more details). Animating with cabasic animation can be more complex because we need to detect the pointer state (used to set the ending position) in - animationDidStop:finished:.
The animation itself will be passed in as a parameter to the delegate method. You may think that the animation can be stored as an attribute in the controller, and then compared in the callback, but in fact it does not work, because the animation parameter passed in by the delegate is a deep copy of the original value, so it is not the same value.
When using - addAnimation:forKey: to add animation to a layer, here is a key parameter that we have set to nil so far. The key here is - animationforkey: method to find the unique identifier of the corresponding animation, and all keys of the current animation can be obtained with animationKeys. If we associate each animation with a unique key, we can cycle all key to each layer, then call -animationForKey: to match the result. Although this is not an elegant implementation.
Fortunately, there is a simpler way. Like all the NSObject subclasses, CAAnimation implements the KVC (key value encoding) protocol, so you can use the - setValue:forKey: and - valueForKey: methods to access properties. But CAAnimation has a different performance: it's more like an NSDictionary, allowing you to set key value pairs at will, even if they don't match the properties declared by the animation class you use.
This means that you can tag any type of animation. Here, we add animation to the pointer of UIView type, so we can simply determine which view the animation belongs to, and then update the pointer of the clock correctly with this information in the delegate method (listing 8.4).
Listing 8.4 tagging animation with KVC
@interface ViewController () @property (nonatomic, weak) IBOutlet UIImageView *hourHand; @property (nonatomic, weak) IBOutlet UIImageView *minuteHand; @property (nonatomic, weak) IBOutlet UIImageView *secondHand; @property (nonatomic, weak) NSTimer *timer; @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; //adjust anchor points self.secondHand.layer.anchorPoint = CGPointMake(0.5f, 0.9f); self.minuteHand.layer.anchorPoint = CGPointMake(0.5f, 0.9f); self.hourHand.layer.anchorPoint = CGPointMake(0.5f, 0.9f); //start timer self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(tick) userInfo:nil repeats:YES]; //set initial hand positions [self updateHandsAnimated:NO]; } - (void)tick { [self updateHandsAnimated:YES]; } - (void)updateHandsAnimated:(BOOL)animated { //convert time to hours, minutes and seconds NSCalendar *calendar = [[NSCalendar alloc] initWithCalendarIdentifier:NSGregorianCalendar]; NSUInteger units = NSHourCalendarUnit | NSMinuteCalendarUnit | NSSecondCalendarUnit; NSDateComponents *components = [calendar components:units fromDate:[NSDate date]]; CGFloat hourAngle = (components.hour / 12.0) * M_PI * 2.0; //calculate hour hand angle //calculate minute hand angle CGFloat minuteAngle = (components.minute / 60.0) * M_PI * 2.0; //calculate second hand angle CGFloat secondAngle = (components.second / 60.0) * M_PI * 2.0; //rotate hands [self setAngle:hourAngle forHand:self.hourHand animated:animated]; [self setAngle:minuteAngle forHand:self.minuteHand animated:animated]; [self setAngle:secondAngle forHand:self.secondHand animated:animated]; } - (void)setAngle:(CGFloat)angle forHand:(UIView *)handView animated:(BOOL)animated { //generate transform CATransform3D transform = CATransform3DMakeRotation(angle, 0, 0, 1); if (animated) { //create transform animation CABasicAnimation *animation = [CABasicAnimation animation]; [self updateHandsAnimated:NO]; animation.keyPath = @"transform"; animation.toValue = [NSValue valueWithCATransform3D:transform]; animation.duration = 0.5; animation.delegate = self; [animation setValue:handView forKey:@"handView"]; [handView.layer addAnimation:animation forKey:nil]; } else { //set transform directly handView.layer.transform = transform; } } - (void)animationDidStop:(CABasicAnimation *)anim finished:(BOOL)flag { //set final position for hand view UIView *handView = [anim valueForKey:@"handView"]; handView.layer.transform = [anim.toValue CATransform3DValue]; }
We successfully recognize the time when each layer stops animation, and then update its transformation to a new value, which is good.
Unfortunately, even after doing this, there is still a problem. Listing 8.4 runs well on the simulator, but when it runs on iOS devices, we find that before - animationDidStop:finished: delegate method call, the pointer will quickly return to the original value. The same happens to the layer color in listing 8.3.
The problem is that the callback method has been called before the animation is complete, but there is no guarantee that this will happen before the attribute animation returns to its original state. This is also a good example of why animation code should be tested on real devices, not just emulators.
We can use a fillMode property to solve this problem, which will be explained in the next chapter. We know that it is more convenient to set it before the animation than to update the property after the animation.
Keyframe animation
Cabasic animation reveals the mechanism behind most implicit animations, which is really interesting, but to explicitly add cabasic animation to a layer is more difficult than implicit animation.
CAKeyframeAnimation is another powerful class that UIKit does not expose. Similar to cabasic animation, CAKeyframeAnimation is also a subclass of CAPropertyAnimation. It still works on a single attribute, but unlike cabasic animation, it is not limited to setting a start and end value, but can be animated according to a series of random values.
Keyframes originate from transmission animation, which means that the dominant animation redraws the current frame (i.e. keyframes) when significant changes occur, and the rest of the painting between each frame (which can be calculated by keyframes) will be completed by skilled artists. The same is true for CAKeyframeAnimation: you provide significant frames, and then Core Animation inserts them between each frame.
We can use the previous example of using color layers to demonstrate, set an array of colors, and then play it out through keyframe animation (listing 8.5)
Listing 8.5 applying a series of color changes using CAKeyframeAnimation
- (IBAction)changeColor { //create a keyframe animation CAKeyframeAnimation *animation = [CAKeyframeAnimation animation]; animation.keyPath = @"backgroundColor"; animation.duration = 2.0; animation.values = @[ (__bridge id)[UIColor blueColor].CGColor, (__bridge id)[UIColor redColor].CGColor, (__bridge id)[UIColor greenColor].CGColor, (__bridge id)[UIColor blueColor].CGColor ]; //apply animation to layer [self.colorLayer addAnimation:animation forKey:nil]; }
Notice that the start and end colors in the sequence are blue, because CAKeyframeAnimation does not automatically set the current value as the first frame (like cabasic animation, which sets fromValue to nil). The animation will jump to the value of the first frame at the beginning, and then suddenly return to the original value at the end of the animation. So in order to smooth the animation, we need the start and end keyframes to match the value of the current attribute.
Of course, you can create an animation with different end and start values, so you need to manually update the attribute and the value of the last frame to be consistent before the animation starts, as discussed earlier.
We use the duration attribute to increase the animation time from the default 0.25 seconds to 2 seconds, so that the animation is not done so fast. Running it, you will find that the animation continues to cycle through colors, but the effect looks a little strange. The reason is that the animation is running at a constant pace. When there is no deceleration in the transition between each animation, it produces a slightly strange effect. In order to make the animation look more natural, we need to adjust the buffer, which will be explained in Chapter 10.
Providing an array of values can make animation according to the color change, but generally speaking, it is not intuitive to use array to describe the animation movement. CAKeyframeAnimation has another way to specify animation, which is to use CGPath. The path attribute can be used to define the motion sequence to draw the animation in an intuitive way by using the Core Graphics function.
Let's use an example of a spaceship following a simple curve. In order to create a path, we need to use a cubic Bezier curve, which is a curve that uses the start point, the end point and two other control points to define the shape. It can be created by using a C-based Core Graphics drawing instruction, but it will be simpler to use the UIBezierPath class provided by UIKit.
We use CAShapeLayer to draw curves on the screen this time. Although it is not necessary for animation, it will make our animation more vivid. After drawing CGPath, we use it to create a CAKeyframeAnimation, and then apply it to our spacecraft. The code is shown in listing 8.6, and the result is shown in Figure 8.1.
Listing 8.6 animating layers along a Bezier curve
@interface ViewController () @property (nonatomic, weak) IBOutlet UIView *containerView; @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; //create a path UIBezierPath *bezierPath = [[UIBezierPath alloc] init]; [bezierPath moveToPoint:CGPointMake(0, 150)]; [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 = bezierPath.CGPath; pathLayer.fillColor = [UIColor clearColor].CGColor; pathLayer.strokeColor = [UIColor redColor].CGColor; pathLayer.lineWidth = 3.0f; [self.containerView.layer addSublayer:pathLayer]; //add the ship CALayer *shipLayer = [CALayer layer]; shipLayer.frame = CGRectMake(0, 0, 64, 64); shipLayer.position = CGPointMake(0, 150); shipLayer.contents = (__bridge id)[UIImage imageNamed: @"Ship.png"].CGImage; [self.containerView.layer addSublayer:shipLayer]; //create the keyframe animation CAKeyframeAnimation *animation = [CAKeyframeAnimation animation]; animation.keyPath = @"position"; animation.duration = 4.0; animation.path = bezierPath.CGPath; [shipLayer addAnimation:animation forKey:nil]; } @end
It's possible to do so, but it seems that it's more because of luck than design. If we adjust the rotation value from m? PI (180 degrees) to 2 * m? PI (360 degrees), and then run the program, we will find that the spacecraft is completely stationary at this time. This is because the matrix here does a 360 degree rotation, which is the same as doing a 0 degree rotation, so the final value does not change at all.
Continue to use the M? PI now, but this time use byValue instead of toValue. You might think it's the same as setting toValue, because 0 + 90 = = 90 degrees, but in fact, the image of the spaceship is bigger and doesn't rotate at all, because the transformation matrix can't stack like the angle value.
So what if you need to animate translation or scaling independently of angle? Because we need to modify the transform attribute, recalculate each transform effect of each time point in real time, and then create a complex keyframe animation based on these, all of which is to make a simple animation of a layer independently.
Fortunately, there is a better solution: to rotate the layers, we can animate the transform.rotation critical path instead of the transform itself (listing 8.9).
Listing 8.9 animating the virtual transform.rotation attribute
@interface ViewController () @property (nonatomic, weak) IBOutlet UIView *containerView; @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; //add the ship CALayer *shipLayer = [CALayer layer]; shipLayer.frame = CGRectMake(0, 0, 128, 128); shipLayer.position = CGPointMake(150, 150); shipLayer.contents = (__bridge id)[UIImage imageNamed: @"Ship.png"].CGImage; [self.containerView.layer addSublayer:shipLayer]; //animate the ship rotation CABasicAnimation *animation = [CABasicAnimation animation]; animation.keyPath = @"transform.rotation"; animation.duration = 2.0; animation.byValue = @(M_PI * 2); [shipLayer addAnimation:animation forKey:nil]; } @end
The result runs very well. The advantages of animating with transform.rotation instead of transform are as follows:
-
We can rotate animation more than 180 degrees in one step without keyframing.
-
You can rotate with a relative value instead of an absolute value (set byValue instead of toValue).
-
Instead of creating CATransform3D, you can use a simple number to specify the angle.
-
It does not conflict with transform.position or transform.scale (also using critical path to make independent animation attributes).
The strange problem with the transform. Rotation attribute is that it doesn't exist. This is because CATransform3D is not an object. It is actually a structure and does not conform to KVC related attributes. transform.rotation is actually a virtual attribute used by CALayer to process animation transformation.
You can't set transform.rotation or transform.scale directly. They can't be used directly. When you animate them, Core Animation automatically updates the transform attribute based on the value computed by the CAValueFunction.
CAValueFunction is used to convert the simple floating-point value we assigned to virtual transform.rotation into the real CATransform3D matrix value used to place the layer. You can change it by setting the valueFunction property of CAPropertyAnimation, so the function you set will override the default function.
CAValueFunction seems to be a very useful mechanism for animating attributes that cannot be simply added (such as transformation matrix), but because the implementation details of CAValueFunction are private, it cannot be defined by inheriting it at present. You can use the constants that Apple has recently provided (currently they are all related to the virtual attributes of the transformation matrix, so there are not many scenarios to use, because these attributes have a default implementation).
8.2 animation group
Animation group
Cabasic animation and CAKeyframeAnimation only work on individual attributes, and CAAnimationGroup can combine these animations together. CAAnimationGroup is another subclass inherited from CAAnimation. It adds an attribute of animations array to combine other animations. We combine the keyframe animation shown in listing 8.6 with the basic animation for adjusting the background color of the layer (listing 8.10), and the result is shown in Figure 8.3.
Listing 8.10 combining keyframe and base animation
- (void)viewDidLoad { [super viewDidLoad]; //create a path UIBezierPath *bezierPath = [[UIBezierPath alloc] init]; [bezierPath moveToPoint:CGPointMake(0, 150)]; [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 = bezierPath.CGPath; pathLayer.fillColor = [UIColor clearColor].CGColor; pathLayer.strokeColor = [UIColor redColor].CGColor; pathLayer.lineWidth = 3.0f; [self.containerView.layer addSublayer:pathLayer]; //add a colored layer CALayer *colorLayer = [CALayer layer]; colorLayer.frame = CGRectMake(0, 0, 64, 64); colorLayer.position = CGPointMake(0, 150); colorLayer.backgroundColor = [UIColor greenColor].CGColor; [self.containerView.layer addSublayer:colorLayer]; //create the position animation CAKeyframeAnimation *animation1 = [CAKeyframeAnimation animation]; animation1.keyPath = @"position"; animation1.path = bezierPath.CGPath; animation1.rotationMode = kCAAnimationRotateAuto; //create the color animation CABasicAnimation *animation2 = [CABasicAnimation animation]; animation2.keyPath = @"backgroundColor"; animation2.toValue = (__bridge id)[UIColor redColor].CGColor; //create group animation CAAnimationGroup *groupAnimation = [CAAnimationGroup animation]; groupAnimation.animations = @[animation1, animation2]; groupAnimation.duration = 4.0; //add the animation to the color layer [colorLayer addAnimation:groupAnimation forKey:nil]; }
8.3 transition
transition
Sometimes for iOS applications, we hope to make some changes to the layout that is difficult to animate through attribute animation. For example, exchange a piece of text and picture, or replace it with a piece of grid view, and so on. Attribute animation only works on the layer's animatable attributes, so if you want to change a non animatable attribute (such as a picture), or add or remove layers from the hierarchy, attribute animation will not work.
So there is the concept of transition. The transition does not animate between two values as smoothly as the attribute animation does, but affects the changes of the entire layer. The transition animation first shows the previous layer appearance, then transitions to the new appearance through an exchange.
In order to create a transition animation, we will use CATransition, which is also a subclass of another CAAnimation. Unlike other subclasses, CATransition has a type and subtype to identify the transformation effect. The type attribute is an NSString type and can be set to the following types:
kCATransitionFade
kCATransitionMoveIn
kCATransitionPush
kCATransitionReveal
So far, you can only use the above four types, but you can customize the transition effect by some other methods, which will be described in detail later.
The default transition type is kcatransionfade, which creates a smooth fade in and fade out effect when you change layer properties.
We have used kcatransionpush in the example in Chapter 7. It creates a new layer, slides in from one side of the edge, and pushes the old layer out from the other side.
Kcatransionmovein and kcatransionreveal are similar to kcatransionpush in that they realize a directional slide animation, but there are some subtle differences. Kcatransionmovein slides in from the top, but it does not push the old soil layer away like push animation. However, kcatransionmovein slides out the original layer to show the new appearance, rather than slide the new layer in Enter.
The next three transition types have a default animation direction. They all slide in from the left, but you can control their direction through subtype, which provides the following four types:
kCATransitionFromRight
kCATransitionFromLeft
kCATransitionFromTop
kCATransitionFromBottom
A simple example of animating non animation attributes with CATransition is shown in listing 8.11. Here we modify the image attribute of UIImage, but neither implicit animation nor CAPropertyAnimation can animate it, because Core Animation doesn't know how to make illustrations. By applying a fade in and fade out transition to the layer, we can ignore its content to make a smooth animation (Figure 8.4). We will try to modify the transition type constant to observe other effects.
Listing 8.11 using CATransition to animate UIImageView
@interface ViewController () @property (nonatomic, weak) IBOutlet UIImageView *imageView; @property (nonatomic, copy) NSArray *images; @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; //set up images self.images = @[[UIImage imageNamed:@"Anchor.png"], [UIImage imageNamed:@"Cone.png"], [UIImage imageNamed:@"Igloo.png"], [UIImage imageNamed:@"Spaceship.png"]]; } - (IBAction)switchImage { //set up crossfade transition CATransition *transition = [CATransition animation]; transition.type = kCATransitionFade; //apply transition to imageview backing layer [self.imageView.layer addAnimation:transition forKey:nil]; //cycle to next image UIImage *currentImage = self.imageView.image; NSUInteger index = [self.images indexOfObject:currentImage]; index = (index + 1) % [self.images count]; self.imageView.image = self.images[index]; } @end
You can see from the code that the transition animation is the same as the previous attribute animation or animation group added to the layer through the - addAnimation:forKey: method. But unlike attribute animation, you can only use CATransition once for a specified layer. Therefore, no matter what value you set for the key of the animation, the key of the transition animation will be set to "transition", that is, the constant kcatransion.
8.4 cancel animation during animation
Cancel animation during animation
As mentioned before, you can use the key parameter in the - addAnimation:forKey: method to retrieve an animation after adding an animation, using the following methods:
- (CAAnimation *)animationForKey:(NSString *)key;
However, it is not supported to modify the animation during the running process of the animation, so this method is mainly used to detect the attributes of the animation or judge whether it is added to the current layer.
To terminate a given animation, you can remove it from the layer as follows:
- (void)removeAnimationForKey:(NSString *)key;
Or remove all animation:
- (void)removeAllAnimations;
Once the animation is removed, the appearance of the layer is updated to the current model layer value. Generally speaking, the animation is automatically removed after the end. Unless the removedOnCompletion is set to NO, if you set the animation to not be automatically removed after the end, you need to manually remove it when it is not needed; otherwise, it will remain in memory until the layer is destroyed.
Let's extend the previous example of spinning a spaceship by adding a button to stop or start the animation. This time we use a non nil value as the key for the animation so that it can be removed later. -The flag parameter in the animationDidStop:finished: method indicates whether the animation ends naturally or is interrupted. We can print it out in the console. If you use the stop button to terminate the animation, it prints NO, and if it is allowed to complete, it prints YES.
Listing 8.15 is the updated sample code, and figure 8.6 shows the results.
Listing 8.15 start and stop an animation
@interface ViewController () @property (nonatomic, weak) IBOutlet UIView *containerView; @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]; } - (IBAction)start { //animate the ship rotation CABasicAnimation *animation = [CABasicAnimation animation]; animation.keyPath = @"transform.rotation"; animation.duration = 2.0; animation.byValue = @(M_PI * 2); animation.delegate = self; [self.shipLayer addAnimation:animation forKey:@"rotateAnimation"]; } - (IBAction)stop { [self.shipLayer removeAnimationForKey:@"rotateAnimation"]; } - (void)animationDidStop:(CAAnimation *)anim finished:(BOOL)flag { //log that the animation stopped NSLog(@"The animation stopped (finished: %@)", flag? @"YES": @"NO"); } @end
8.5 summary
summary
In this chapter, we cover attribute animation (you can have more specific control over individual layer attribute animation), animation group (combining multiple attribute animations into a single unit) and excessive (affecting the whole layer, which can be used to make any type of animation for any content of the layer, including the addition and removal of sub layers).
In Chapter 9, we'll continue to learn the CAMediaTiming protocol to see how Core Animation deals with lost time.