THE MENTAL BLOG
THE MENTAL BLOG
2010
Given how easy it is to use a flip transition in iOS, it is perhaps surprising that nothing similar is available on the Mac. After all, the Mac was the first platform to see widespread use of the animation in Dashboard.
Recently, while developing Mental Case 2, a major rewrite of our flagship study app, I wanted to perform a flip to give the user access to advanced options on the back of a card-like view. I thought it would be trivial, but ended up costing quite a few hours to get right. I considered various options, including Core Image and Quartz Composer, but eventually settled on perhaps the most obvious choice: Core Animation.
There was a particularly good post by Mike Lee on exactly this topic, but it has unfortunately disappeared from the internets. I was able to track down the source code, and used it as the basis for the solution I present here.
The solution itself involves first extracting bitmap images for the front and back views, and using these to create new layers. The following category on NSView makes this easy enough:
@implementation NSView (MCAdditions)
-(CALayer *)layerFromContents
{
CALayer *newLayer = [CALayer layer];
newLayer.bounds = self.bounds;
NSBitmapImageRep *bitmapRep = [self bitmapImageRepForCachingDisplayInRect:self.bounds];
[self cacheDisplayInRect:self.bounds toBitmapImageRep:bitmapRep];
newLayer.contents = (id)bitmapRep.CGImage;
return newLayer;
}
@end
(Note that all code assumes garbage collection is being used.)
Mike’s original code performed the animations directly on the backing layers of the NSViews, but I had difficulties with this approach, and Apple generally advise you not to mess with a view’s layer behind its back.
With the new layers created, it is just a question of animating them. The code to create the CAAnimations used is based heavily on Mike’s original code.
+(CAAnimation *)flipAnimationWithDuration:(NSTimeInterval)aDuration
forLayerBeginningOnTop:(BOOL)beginsOnTop
scaleFactor:(CGFloat)scaleFactor {
// Rotating halfway (pi radians) around the Y axis gives the appearance of flipping
CABasicAnimation *flipAnimation =
[CABasicAnimation animationWithKeyPath:@"transform.rotation.y"];
CGFloat startValue = beginsOnTop ? 0.0f : M_PI;
CGFloat endValue = beginsOnTop ? -M_PI : 0.0f;
flipAnimation.fromValue = [NSNumber numberWithDouble:startValue];
flipAnimation.toValue = [NSNumber numberWithDouble:endValue];
// Shrinking the view makes it seem to move away from us, for a more natural effect
// Can also grow the view to make it move out of the screen
CABasicAnimation *shrinkAnimation = nil;
if ( scaleFactor != 1.0f ) {
shrinkAnimation = [CABasicAnimation animationWithKeyPath:@"transform.scale"];
shrinkAnimation.toValue = [NSNumber numberWithFloat:scaleFactor];
// We only have to animate the shrink in one direction, then use autoreverse to "grow"
shrinkAnimation.duration = aDuration * 0.5;
shrinkAnimation.autoreverses = YES;
}
// Combine the flipping and shrinking into one smooth animation
CAAnimationGroup *animationGroup = [CAAnimationGroup animation];
animationGroup.animations = [NSArray arrayWithObjects:flipAnimation, shrinkAnimation, nil];
// As the edge gets closer to us, it appears to move faster.
// Simulate this in 2D with an easing function
animationGroup.timingFunction =
[CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
animationGroup.duration = aDuration;
// Hold the view in the state reached
animationGroup.fillMode = kCAFillModeForwards;
animationGroup.removedOnCompletion = NO;
return animationGroup;
}
This method determines the starting and ending angles of the rotation based on whether it is on top or at the back. It also applies an optional scale animation. On the iPhone, it looks good to shrink the view as it flips. This gives the impression it is pulling away from the screen, flipping, and then settling back down again. On the Mac, I find it looks better to scale up the view as it flips, so that it looks like it is moving out of the screen, flipping, and then settling down again. This method can support both options.
With everything in place, it is a question of applying the animations, and swapping the views at the appropriate time.
-(IBAction)flip:(id)sender
{
if ( isFlipped ) {
topView = backView;
bottomView = frontView;
}
else {
topView = frontView;
bottomView = backView;
}
CAAnimation *topAnimation =
[CAAnimation flipAnimationWithDuration:duration forLayerBeginningOnTop:YES
scaleFactor:1.3f];
CAAnimation *bottomAnimation =
[CAAnimation flipAnimationWithDuration:duration forLayerBeginningOnTop:NO
scaleFactor:1.3f];
bottomView.frame = topView.frame;
topLayer = [topView layerFromContents];
bottomLayer = [bottomView layerFromContents];
CGFloat zDistance = 1500.0f;
CATransform3D perspective = CATransform3DIdentity;
perspective.m34 = -1. / zDistance;
topLayer.transform = perspective;
bottomLayer.transform = perspective;
bottomLayer.frame = topView.frame;
bottomLayer.doubleSided = NO;
[hostView.layer addSublayer:bottomLayer];
topLayer.doubleSided = NO;
topLayer.frame = topView.frame;
[hostView.layer addSublayer:topLayer];
[CATransaction begin];
[CATransaction setValue:[NSNumber numberWithBool:YES] forKey:kCATransactionDisableActions];
[topView removeFromSuperview];
[CATransaction commit];
topAnimation.delegate = self;
[CATransaction begin];
[topLayer addAnimation:topAnimation forKey:@"flip"];
[bottomLayer addAnimation:bottomAnimation forKey:@"flip"];
[CATransaction commit];
}
-(void)animationDidStop:(CAAnimation *)animation finished:(BOOL)flag;
{
isFlipped = !isFlipped;
[CATransaction begin];
[CATransaction setValue:[NSNumber numberWithBool:YES] forKey:kCATransactionDisableActions];
[hostView addSubview:bottomView];
[topLayer removeFromSuperlayer];
[bottomLayer removeFromSuperlayer];
topLayer = nil; bottomLayer = nil;
[CATransaction commit];
}
Note that a perspective transform is applied to the flipping layers, to make them look 3D.
CGFloat zDistance = 1500.0f;
CATransform3D perspective = CATransform3DIdentity;
perspective.m34 = -1. / zDistance;
topLayer.transform = perspective;
bottomLayer.transform = perspective;
Most of the code is pretty self explanatory. If you are happy to use it as is, all you need to do is create an instance of MCViewFlipController, and invoke the flip: action.
-(void)applicationDidFinishLaunching:(NSNotification *)aNotification {
flipController = [[MCViewFlipController alloc] initWithHostView:hostView
frontView:frontView backView:backView];
}
-(IBAction)flip:(id)sender
{
[flipController flip:self];
}
You can download the class, together with a simple sample app, here.
FLIPPIN’ OUT AT NSVIEW
22/09/10
Flipping a view is a commonly used animation, particularly on iOS. But on the Mac, it is surprisingly difficult to flip between views. This post gives you the know-how and source code to get the job done with Core Animation.
Photo by denmar - http://flic.kr/p/fgtHE