Custom curve UIView animations
I generally favour using the block based view animation methods defined on UIView
over Core Animation for several reasons. Most importantly, I think that the syntax of these methods is more concise than first defining an animation object instance, then setting all properties and finally adding the animation object instance to the layer of the view that we want to animate.
However, there are some situations where the API exposed by these methods is not sufficient, such as the desire for an animation curve other than the predefined UIViewAnimationOption
curves. Luckily, Apple also provides a CADisplayLink
class that allows us to synchronise the execution of code with the refresh rate of the screen.
In this post, I’d like to share a convenience method that I created recently in a category on UIView
[^1]. With this convenience method, a property of a view can be animated with a custom animation curve. I will first discuss the building blocks of the convenience method and then give an example application.
Building blocks
To make the convenience method work, there are several building blocks that need to be implemented. First of all, we need to find a way to encode the desired animation curve, and retrieve its value at a specific point in time. While Apple's CAMediaTimingFunction
class allows us to do the first, it offers no method to evaluate the function described by the curve for a certain input value. Therefore, for my implementation I resorted to GNUstep QuartzCore's CAMediaTimingFunction class, which clones Apple's CAMediaTimingFunction
implementation. To avoid naming conflicts, I renamed this class to GSQMediaTimingFunction
and extended its public interface to expose the method - (CGFloat)evaluateYAtX:(CGFloat)x
.
The second building block, LLTimedAnimation
, sets up and manages a display-link based animation. The class is initialised as follows:
- (instancetype)initWithMediaTimingFunction:(GSQMediaTimingFunction *)timingFunction duration:(CGFloat)duration animationStateForProgress:(void(^)(CGFloat progress))animationStateForProgress completion:(void (^)(void))completion
{
self = [super init];
if (self) {
self.duration = duration;
self.timingFunction = timingFunction;
self.animationStateForProgress = animationStateForProgress;
self.completion = completion;
}
return self;
}
After initialisation, our LLTimedAnimation
instance is set up with the duration of the animation, the timing function to use to calculate the progress, a completion block and, most importantly, a block animationStateForProgress
which will be called whenever the CADisplayLink
fires. As a parameter, this block takes the animation's progress normalised to a value in the range {0,1}
. Note that at any intermediary point in time, the actual value may lay outside this range if some of the parameters passed to GSQMediaTimingFunction
's + (id) functionWithControlPoints:
are smaller than 0 or greater than 1. However, as per Apple's documentation, the start and end values are guaranteed to be 0 and 1 respectively.
The LLTimedAnimation
can be started by calling the begin
method on an instance:
- (void)begin
{
CADisplayLink *displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(performAnimationFrame:)];
[displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
}
Upon calling this method a CADisplayLink
is set up with the LLTimedAnimation
instance as target; ensuring that whenever the display link fires, the method performAnimationFrame:
will be called with the display link as parameter. The display link is then addeed to the main run loop. Since the display link retains its target, the LLTimedAnimation
instance will be kept in memory as long as the display link is attached to the run loop.
The final building block is the performAnimationFrame:
method. The task of this method is straightforward; it uses the timestamp of the display link to calculate the progress of the animation with respect to its total duration and then calls evaluateYAtX:
on the media timing function to get the actual progress value according to the curve defined earlier. If the time spent within the animation is greater than or equal to the allotted duration, the progress is set to 1.0 to ensure a clean end state. This is required since this method may get called sligthly later than the actual end time due to the nature of a display link.
The block stored in self.animationStateForProgress
upon initialization is then called with the calculated progress value as a parameter. Finally, if the defined animation duration has finished, and therefore the animation has completed, we remove the display link from the run loop and call the completion block. Note that by removing the display link from the run loop, the class cluster consisting of the CADisplayLink
and LLTimedAnimation
instance is cleaned up.
- (void)performAnimationFrame:(CADisplayLink *)sender
{
CGFloat durationDone = 0;
CGFloat progress;
if (sender) {
if (self.beginTime && self.beginTime != 0) {
durationDone = (sender.timestamp - self.beginTime);
} else {
self.beginTime = sender.timestamp;
}
if (self.duration > 0) {
progress = [self.timingFunction evaluateYAtX:durationDone/self.duration];
} else {
progress = 1.0;
}
} else {
progress = 1.0;
}
if (durationDone >= self.duration) {
// Make sure the end state is 'clean'
progress = MAX(progress, 1.0);
}
self.animationStateForProgress(progress);
if (durationDone >= self.duration) {
[sender removeFromRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
if (self.completion) {
self.completion();
}
}
}
Example application
As an example application, consider a label that may contain a numeric value only. Whenever we set a new value for the label, we do not want the label to update instantly, but rather want it to count toward it's final value starting from it's current value. Using our category as defined above, we can easily achieve such an effect.
First of all, we create a class LLCountingLabel
which subclasses UILabel
. The label defines a property currentValue
that is initialized to have the value 0:
- (instancetype)init
{
self = [super init];
if (self) {
_currentValue = 0;
}
return self;
}
Furthermore, we define a method - (void)setCounter:(NSUInteger)newCounterValue
in the public interface of our class, and in its implementation, we calculate the difference between the current value of the label and it's new value. We then call our convenience method on UIView
and in the animationStateForProgress
callback block, we calculate the value of the label relative to the progress of the animation and set this value as the text of the label.
- (void)setCounter:(NSInteger)newCounterValue
{
NSInteger valueAtStart = self.currentValue;
NSInteger diff = newCounterValue - valueAtStart;
@weakify(self);
[UIView animateWithMediaTimingFunction:[GSQMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut] duration:1.0f animationStateForProgress:^(CGFloat progress) {
@strongify(self);
if (! self) {
return;
}
NSInteger valueForProgress = valueAtStart + (diff * progress);
self.currentValue = valueForProgress;
[self setText:[NSString stringWithFormat:@"%@", @(valueForProgress)]];
} completion:nil];
}
Since the animationStateForProgress
callback block is called whenever the screen refreshes, the value of the label is updated for every frame that is rendered. The result is as shown above. The code for the example is available on Github.
[^1]:
I started writing this post in February, but never got to finish it up until now. Meanwhile, Facebook released their awesome POP Animation Framework, which contains a POPCustomAnimation
class that is very similar to what I'll describe in this post. I still think, though, that this post is a nice illustration of how to simplify animation code.