Monthly Archives: April 2017

Animating Views with Custom Timing Functions on iOS

View animation on iOS is a ubiquitous part of the UI. From presenting & dismissing view controllers, to navigation; and on a smaller scale, subviews like buttons and labels, animations are everywhere and are an important element of adding polish to the feel and look of an app.

iOS already provides default animations for the aforementioned actions on view controllers and navigation controllers as well as a few others, but animating subviews or customizing view/navigation controller transitions are left up to the developer. Typically this is done using the block-based animation methods in the UIView class (-animateWithDuration:animations:, etc.). One of these variants allows you to pass in an option with four predefined animation curves or timing functions: ease in, ease out, ease in-out, and linear (the default). The timing function is what drives the pacing of the animation. Linear is fine for certain properties like alpha or color, but for movement-based animations, a curve with acceleration/deceleration feels much better. However, the predefined easing functions are very subtle, and it’s very likely there are times that you want something better.

For more control and flexibility, layer-based animations are available. On iOS, each view is layer-backed by a CALayer object that defines the view’s visual appearance and geometry. In fact, when manipulating a view’s properties such as frame, center, alpha, etc., it is actually applying those changes to its backing CALayer object.

Animating CALayers is done through one of the subclasses of CAAnimation and offers additional options for defining animations, including basic and key-frame animation. Like the block-based methods of UIView, a timing function can be specified and has the same set of predefined easing curves with an additional system default option. The timing function in this case is an instance of CAMediaTimingFunction, and it also allows you to create a custom function with a call to +functionWithControlPoints::::. However, this custom timing function is created by supplying the control points of a cubic Bezier curve, and while that does offer some additional flexibility in creating more unique animations, it can’t handle piecewise functions (e.g. for bounces), multiple curves (e.g. elastic or spring-like functions), etc. To achieve those effects, you would either have to stitch animations together, or use a combination of custom Bezier curves with key-frame animations. Both approaches end up being tedious and cumbersome to deal with.

Ideally, the block-based animation methods of UIView would allow you to pass in a custom timing function block like this:

[UIView animateWithDuration:timingFunction:animations:];

where timingFunction is just a block that takes in a value for the current animation time, it’s duration, and returns a fractional value between 0 and 1. That’s all a timing function is! This would allow you to, for example, use any of the easing functions here. Or to supply your own homebuilt version. So in the true spirit of DIY (since Apple couldn’t be bothered to do it), let’s make that method!

To begin with, here is the actual signature of our method with an example of how it is used:

[UIView fs_animateWithDuration:1.2 timingFunction:FSEaseOutBack animations:^{
    aView.fs_center = newCenter;
} completion:^{
    centerYConstraint.constant = ...;
    centerXConstraint.constant = ...;
}];

If constraint-based layout is being used, the same rules apply when using other animation methods, so once an animation has completed, constraints need to be updated to reflect the new position and/or bounds of the view.

Since the block-based animation methods are class methods, that clues us in that we need to keep some global state for in-flight animations. For now, I’m not going to worry about supporting multiple concurrent or nested animations, so I’ll keep it simple and make the state static.

typedef NSTimeInterval(^FSAnimationTimingBlock)(CGFloat,CGFloat);
typedef void(^FSCompletionBlock)(void);

static const NSTimeInterval fsAnimationFrameTime = 1.0/60.0; // 60 fps
static FSAnimationTimingBlock fsAnimationTimingFunc;
static FSCompletionBlock fsAnimationCompletionFunc;
static NSTimeInterval fsAnimationDuration;
static NSMutableArray<NSTimer*> *fsAnimationTimers;
static BOOL fsAnimationInFlight = NO;
static NSUInteger fsAnimationTimerCount = 0;
static NSUInteger fsAnimationTimersCompleted = 0;

Now we create a category on UIView for our block-based animation method:

@interface UIView (FSAnimations)
@property (nonatomic) CGPoint fs_center;
@property (nonatomic) CGRect fs_bounds;
@property (nonatomic) CGFloat fs_alpha;
// Additional animatable properties to support...

+ (void)fs_animateWithDuration:(NSTimeInterval)duration timingFunction:(FSAnimationTimingBlock)timingFunc animations:(void(^)(void))animations completion:(FSCompletionBlock)completion;

@end

@implementation UIView (FSAnimations)

+ (void)fs_animateWithDuration:(NSTimeInterval)duration timingFunction:(FSAnimationTimingBlock)timingFunc animations:(void (^)(void))animations completion:(FSCompletionBlock)completion {
    // Set up global state for this animation.
    fsAnimationTimingFunc = [timingFunc copy];
    fsAnimationCompletionFunc = [completion copy];
    fsAnimationDuration = duration;
    fsAnimationInFlight = YES;
    fsAnimationTimers = [NSMutableArray array];
    
    // Run the animation block which queues up the animations for each property of the views contained in the block.
    animations();
    
    // Run the animations.
    FSLaunchQueuedAnimations();
}

@end

The reason we define custom animatable properties like fs_center, fs_alpha, etc. instead of setting them directly (i.e. aView.center = newCenter;) will become clearer as we move forward. In the method above, the global state of the animation is set up and then we execute the block that defines which properties on which views will be animated. Recall that we had a line that looks like this in the example above:

aView.fs_center = newCenter;

This code is executed by the animations block in the method above, and here is what that code does:

- (void)setFs_center:(CGPoint)newCenter {
    NSAssert(fsAnimationInFlight, @"Property %@ can only be called inside a 'fs_animateWithDuration:...' block.", NSStringFromSelector(_cmd));
    
    CGPoint baseValue = self.center;
    CGPoint targetValue = newCenter;
    
    __block NSTimeInterval currentTime = 0.0;
    NSTimer *timer = [NSTimer timerWithTimeInterval:fsAnimationFrameTime repeats:YES block:^(NSTimer * _Nonnull timer) {
        currentTime += timer.timeInterval;
        NSTimeInterval t = fsAnimationTimingFunc(currentTime, fsAnimationDuration);
        CGPoint val = CGPointMake(lerp(baseValue.x, t, targetValue.x), lerp(baseValue.y, t, targetValue.y));
        self.center = val;
        
        FSCheckTimerAndCompleteIfLast(timer, currentTime);
    }];
    
    [fsAnimationTimers addObject:timer];
}

(We can now see the reason for using custom properties like fs_center instead of the view’s center property. While it is possible to override existing properties in a category, we need to set the new value of the property inside it, which in this case would cause infinite recursion.)

When an animatable fs_ property is called inside an animation block, it sets up the local state for that property, including its timer that is queued up to run after doing this for all properties in the block. Inside the timer’s block, we can see that the timing function we supply is used to calculate a value, t, used to blend between the initial state and the target state of the property (lerp here is just a normal linear interpolation function). The intermediate value of the property is set, and the block then checks to see if this timer has reached the end of the animation’s duration. If it has, and it’s the last timer to do so, it will run the animation’s completion block we provided and then reset it’s global state.

static void
FSCheckTimerAndCompleteIfLast(NSTimer *timer, NSTimeInterval currentTime) {
    if (currentTime >= fsAnimationDuration) {
        [timer invalidate];
        
        if (++fsAnimationTimersCompleted == fsAnimationTimerCount) {
            fsAnimationInFlight = NO;
            fsAnimationCompletionFunc();

            fsAnimationCompletionFunc = nil;
            fsAnimationTimingFunc = nil;            
            fsAnimationTimerCount = 0;
            fsAnimationTimersCompleted = 0;
            [fsAnimationTimers removeAllObjects];
            fsAnimationTimers = nil;
        }
    }
}

The function to start the animations simply runs through all the queued-up timers in the array and schedules them on the current run loop (which must be on the main thread since all drawing in Core Graphics must be done on the main thread). This is done to keep the animations as closely synced as possible as opposed to starting each timer in their respective property’s setter (which could cause problems with short durations and/or a long list of views and properties being animated).

static void
FSLaunchQueuedAnimations() {
    fsAnimationTimerCount = fsAnimationTimers.count;
    [fsAnimationTimers enumerateObjectsUsingBlock:^(NSTimer * _Nonnull timer, NSUInteger idx, BOOL * _Nonnull stop) {
        [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];
    }];
}

The only thing remaining is an example of an easing function that can be used as a custom timing function for this method. This is one of Robert Penner’s Easing Functions (the link referenced above is a handy visual aid for them all).

NSTimeInterval(^FSEaseOutBack)(CGFloat, CGFloat) = ^NSTimeInterval(CGFloat t, CGFloat d) {
    CGFloat s = 1.70158;
    t = t/d - 1.0;
    return (t * t * ((s + 1.0) * t + s) + 1.0);
};

With that, we have a concise and convenient way of animating using a much wider variety of easing functions. This method can quite easily be used to customize existing transition animations on view/navigation controllers using their UIViewControllerTransitioningDelegate property.

Finally, here is a short demo of several animations using the method we just created. The timing functions used in this demo include EaseOutBack, EaseOutElastic, EaseOutBounce, and EaseInBack.

animation_demo