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:
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!