Interactive Card Flip Animation Techniques

We had a requirement a few months ago to add an interactive card flip animation to our Advanced English for Business product. We had already been using the standard transitionWithView method of UIView.

[UIView transitionWithView:self.cardContainerView
                  duration:0.5f
                   options:UIViewAnimationOptionTransitionFlipFromRight
                animations:^{
                    
                    [self.frontCard removeFromView];
                    [self presentCard:self.backCard];
                    
                } completion:nil];

Everyone on the team really liked the way that this animation looked, but we wanted it to be controllable in an interactive way by the user (i.e. the user uses touch to control the speed and direction of the card flip).

Since there is no interactivity built into the transitionWithView call, we had to rebuild the look of this animation from scratch and control it ourselves to add interactivity.

First, we had to replicate the stock transitionWithView animation. We were helped tremendously by the following blog entries that had already figured out the details: Flipping with proper perspective distortion in Core Animation and Introduction to 3D drawing in Core Animation. I’ll briefly summarize the main points.

There are four things going on in the animation:

  1. The front card rotates 180° and the back view also rotates 180° in the opposite direction. The front card should disappear behind the back card.
  2. The cards move into shadow as they rotate out of view.
  3. There is perspective distortion of the card (i.e. the part of the card that is closest to the user should become bigger and the part furthest away should be smaller.)
  4. The camera (by which I mean the user’s view onto the card) should pull back slightly from the card midway through the animation. This allows the user to see the perspective distortion better and makes the animation feel a lot better and look more realistic.

It seems like you could just do the first step and call it done, but it feels wrong, especially if you have seen the transitionWithView stock animation.

Here’s the code to make that happen:

const CGFloat cardAnimationDuration = 1.0;
const CGFloat cameraPullBackScale = 0.85f;

- (void)configureAnimationWithCards
{   
    UIColor * flipShadowColor = [UIColor colorWithRed:0.0 green:0.0 blue:0.0 alpha:0.8];
    
    self.backCard.layer.doubleSided = NO;
    self.frontCard.layer.doubleSided = NO;
    
    CAKeyframeAnimation *flipToBackAnimation = [self animationWithKeyPath:@"transform"];
    flipToBackAnimation.values = @[[self valueForRotation:0.0f],
                                   [self valueForRotation:1.5f * M_PI andScale:cameraPullBackScale],
                                   [self valueForRotation:M_PI]];
    
    CAKeyframeAnimation *flipToBackBackgroundAnimation = [self animationWithKeyPath:@"backgroundColor"];
    flipToBackBackgroundAnimation.values = @[(id)[UIColor clearColor].CGColor,
                                             (id)flipShadowColor.CGColor,
                                             (id)flipShadowColor.CGColor];
    
    CAKeyframeAnimation *flipToFrontAnimation = [self animationWithKeyPath:@"transform"];
    flipToFrontAnimation.values = @[[self valueForRotation:M_PI],
                                    [self valueForRotation:0.5f * M_PI andScale:cameraPullBackScale],
                                    [self valueForRotation:0.0f]];
    
    CAKeyframeAnimation *flipToFrontBackgroundAnimation = [self animationWithKeyPath:@"backgroundColor"];
    flipToFrontBackgroundAnimation.values = @[(id)flipShadowColor.CGColor,
                                              (id)flipShadowColor.CGColor,
                                              (id)[UIColor clearColor].CGColor];
    
    self.frontCard.layer.transform = [[flipToBackAnimation.values lastObject] CATransform3DValue];
    self.backCard.layer.transform = [[flipToFrontAnimation.values lastObject] CATransform3DValue];
    
    [self.frontCard.layer addAnimation:flipToBackAnimation forKey:kCATransition];
    [self.frontCard.shadowView.layer addAnimation:flipToBackBackgroundAnimation forKey:kCATransition];
    [self.backCard.layer addAnimation:flipToFrontAnimation forKey:kCATransition];
    [self.backCard.shadowView.layer addAnimation:flipToFrontBackgroundAnimation forKey:kCATransition];
    
    [self setLayerSpeed:0.0];
}

- (CATransform3D)transformForRotation:(CGFloat)radians andScale:(CGFloat)scale
{
    CATransform3D t = CATransform3DIdentity;
    t.m34 = -1.0f / 850.0f; // add perspective distortion!
    t = CATransform3DRotate(t, radians, 0, 1, 0);
    t = CATransform3DScale(t, scale, scale, scale);
    return t;
}

- (CATransform3D)transformForRotation:(CGFloat)radians
{
    return [self transformForRotation:radians andScale:1.0f];
}

- (NSValue *)valueForRotation:(CGFloat)radians
{
    return [self valueForRotation:radians andScale:1.0f];
}

- (NSValue *)valueForRotation:(CGFloat)radians andScale:(CGFloat)scale
{
    CATransform3D t = [self transformForRotation:radians andScale:scale];
    return [NSValue valueWithCATransform3D:t];
}

- (CAKeyframeAnimation *)animationWithKeyPath:(NSString *)keyPath
{
    CAKeyframeAnimation * animation = [CAKeyframeAnimation animationWithKeyPath:keyPath];
    animation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
    animation.removedOnCompletion = NO;
    animation.duration = cardAnimationDuration;
    return animation;
}

This code assumes a couple of things have been setup. Our frontCard and backCard have been added to our view, and they both have a simple UIView property called shadowView. We’ll use those views to accomplish step 2 from above with the help of the flipToBackBackgroundAnimation and flipToFrontBackgroundAnimation animations. We’re also marking the card layers as being not double sided so that they won’t display when rotated.

The flipToBackAnimation and flipToFrontAnimation animations have most of the magic. If you look at the transformForRotation method, it has the numbers for the rest of our animation. We have a CATransform3DRotate to perform the actual card rotation. We have a CATransform3DScale to perform the camera pullback. And, finally, we modify the m34 member of the CATransform3D transformation matrix to accomplish the perspective distortion. Coming up with that value is largely empirical. If you want to know more, see CA’s 3D Model

This looks great, and if you just let the animations run, looks identical to transitionWithView. But we wanted to control the animation via touch. Enter CAMediaTiming and CADisplayLink.

You can look those APIs up in the apple documentation, but in a nutshell, CAMediaTiming is an interface to model a timing system, and CAAnimation implements this protocol. This means that, by altering elements of the CAMediaTiming interface (specifically the speed and timeOffset properties), we can control exactly where in the animation we are instead of just having the animation run to its end. We have total control!

CADisplayLink is a special timer that is locked to the display’s refresh rate. We can get a callback every 1/60 of a second and can update our animation smoothly. You could use a NSTimer to do this, but CADisplayLink is guaranteed to run every 16.667 milliseconds, and NSTimer is not (in action, it’s more like every 30–100 ms, so your animation will feel choppy). Use CADisplayLink with caution and great respect!

We had the following methods to control the animation:

- (void)addAnimationProgress:(CGFloat)percentDoneDelta
{
    [self setAnimationTimeOffset:self.frontCard.layer.timeOffset + cardAnimationDuration * percentDoneDelta];
}

- (void)setAnimationTimeOffset:(CGFloat)timeOffset
{
    CGFloat fencedTimeOffset = MIN( MAX( timeOffset, 0.0f ), cardAnimationDuration);
    
    [self setLayerAnimationTimeOffset:fencedTimeOffset];
    
    if (timeOffset < 0.0001) {
        if (self.animationState != RSCardAnimationStateBegin) {
            self.animationState = RSCardAnimationStateBegin;
            [self stopDisplayLink];
            [self animationReachedStart];
        }
    }
    else if (timeOffset >= cardAnimationDuration) {
        self.animationState = RSCardAnimationStateEnd;
        [self stopDisplayLink];
        [self animationReachedEnd];
    }
    else if (self.animationState == RSCardAnimationStateEnd) {
        self.animationState = RSCardAnimationStateInProgress;
        [self animationWithdrawnFromEnd];
    }
    else if (self.animationState == RSCardAnimationStateBegin) {
        self.animationState = RSCardAnimationStateInProgress;
        [self animationWithdrawnFromStart];
    }
}

- (void)setLayerSpeed:(CGFloat)speed
{
    self.frontCard.layer.speed = speed;
    self.frontCard.shadowView.layer.speed = speed;
    self.backCard.layer.speed = speed;
    self.backCard.shadowView.layer.speed = speed;
}

- (void)setLayerAnimationTimeOffset:(CGFloat)timeOffset
{
    self.frontCard.layer.timeOffset = timeOffset;
    self.frontCard.shadowView.layer.timeOffset = timeOffset;
    self.backCard.layer.timeOffset = timeOffset;
    self.backCard.shadowView.layer.timeOffset = timeOffset;
}

The main public method here is addAnimationProgress, which takes a “percent done” for the card flip. We have that tied to a pan gesture recognizer, but you could tie it to anything. This method calculates what the CAAnimation expects for the time value for the percent completion value. That value is passed on to setAnimationTimeOffset where it is fenced and set to the layers. (The setAnimationTimeOffset method also does some state machine stuff to figure out if we have arrived at the animation begin, left the animation begin, arrived at the animation end or left the animation end. Our code performs some UI actions triggered by these events - for example, we enable a button when you reach the end of the animation and disable it when we leave the animation end.).

So, this is cool, but, if you lift up your finger from our gesture recognizer, the animation freezes. This is not great UI. The user would expect the card to have physics and continue animating with its final velocity.

We can fix that. We can get a velocity from the pan gesture recognizer on completion, so if we are not done with our animation at the end, we pass this velocity onto our animation handling code, using this method:

- (void)completeAnimationWithVelocity:(CGFloat)speed
{
    // limit amount of progress that can be made on one cycle
    // note that we limit how fast a user can swipe, so we can just set kMaximumSpeed without having to ease
    if (speed > 0) {
        self.speed = MIN( speed, kMaximumSpeed );
        self.targetSpeed = MAX( self.speed, kMinimumSpeed );
    }
    else {
        self.speed = MAX( speed, -kMaximumSpeed );
        self.targetSpeed = MIN( self.speed, -kMinimumSpeed );
    }
    
    [self startDisplayLink];
}

You’ll notice we do some simple fencing of the input speed. If a user has their finger going very fast, the animation can flip over instantly, which feels very wrong. Objects in the real world have inertia.

But, we also set a targetSpeed and call startDisplayLink. What does that code do?

- (void)startDisplayLink
{
    if (!self.displayLink) {
        self.displayLink = [CADisplayLink displayLinkWithTarget:self
                                                       selector:@selector(updateAnimationFromDisplayLink:)];
        [self.displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
        self.lastSpeedUpdateTime = CACurrentMediaTime();
    }
}

- (void)stopDisplayLink
{
    if (self.displayLink != nil) {
        [self.displayLink invalidate];
        self.displayLink = nil;
    }
}

Here’s where we start to use our CADisplayLink. As mentioned above, this sets us up to get a notification on every display refresh tick. What do we do when we get this notification?

- (void)updateAnimationFromDisplayLink:(CADisplayLink *)displayLink
{
    CFTimeInterval timeElapsed = [displayLink timestamp] - self.lastSpeedUpdateTime;
    self.lastSpeedUpdateTime = [displayLink timestamp];

    [self setAnimationTimeOffset:self.frontCard.layer.timeOffset + timeElapsed / cardAnimationDuration * self.speed];
    
    static const CGFloat kEasingTime = 0.2f;
    if (fabsf(self.targetSpeed - self.speed) > 0.01) {
        self.speed += (self.targetSpeed - self.speed) * (timeElapsed / kEasingTime);
    }
}

First, we calculate an exact amount of time that has passed in timeElapsed. It might be tempting to assume 16.6667 ms here, since we are getting called every refresh update. But, we’d like to ensure this code works on devices that have refresh rates that are not 60 Hz.

We then just call our old method setAnimationTimeOffset to set the progress of the animation based on the time elapsed and our speed.

The last line does a little easing of the animation speed to get us to a target value that we set up in completeAnimationWithVelocity. This allows us to set up a fast velocity or slow velocity based on the user’s finger speed, but still ease into a more reasonable speed so the animation doesn’t take too long or too short of a time.

That’s it! A fun animation that feels natural and physically correct – and looks great.

Check out our sample project at the Rosetta Stone GitHub for an illustration of how this works. Open an issue there if you have questions, corrections or feedback.

David Coufal

dcoufal@rosettastone.com


David Coufal is a Lead iOS Software Engineer at Rosetta Stone in Boulder, CO. He is a Colorado native, graduate of Caltech and MIT and 15-year veteran of the software engineering industry. davidcoufal.com


Rosetta Stone has development offices in Boulder, CO, San Francisco, CA, Seattle, WA, Austin, TX and Harrisonburg, VA. We are always looking for talented engineers so please check out jobs.rosettastone.com if you want to come join us help the world learn!