This two part mini-series will teach you how to create an impressive page-folding effect with Core Animation. In this installment, you'll learn the steps necessary to polish the page fold created previously and you'll build a more interactive folding experience using pinch gestures.
Introduction
In the first part of this two-part tutorial, we took an existing freehand drawing app which allowed the user to sketch on the screen with his finger and we implemented a 3D folding effect on the drawing canvas that gave the visual impression of a book being folded up along its spine. We achieved this using CALayer
s and transformations. We hooked up the effect to a tap gesture that caused the folding to happen in increments, animating smoothly between one increment and the next.
In this part of the tutorial, we'll look at improving both the visual aspects of the effect (by adding curved corners to our pages, and letting our pages cast a shadow on the background) as well as making the folding-unfolding more interactive, by letting the user control it with a pinch gesture. Along the way, we'll learn about several things: a CALayer
subclass called CAShapeLayer
, how to mask a layer to give it a non-rectangular shape, how to enable a layer to cast a shadow, how to configure the shadow's properties, and a bit more about implicit layer animation and how to make these animations play nice when we add user interaction into the mix.
The starting point for this part will be the Xcode project we ended up with at the conclusion of the first tutorial. Let's proceed!
Shaping the Page Corners
Recall that each of the left and right pages was an instance of CALayer
. They were rectangular in shape, placed over the left and right halves of the canvas, abutting along the vertical line running through the center. We drew the contents (i.e. the user's freehand sketch) of the left and right halves of the canvas into these two pages.
Even though a CAlayer
upon creation starts out with rectangular bounds (like UIView
s), one of the cool things we can do to layers is clip their shape according to a "mask", so that they're no longer restricted to being rectangular! How is this mask defined? CALayer
s have a mask
property which is a CALayer
whose content's alpha channel describes the mask to be used. If we use a "soft" mask (alpha channel has fractional values) we can make the layer partially transparent. If we use a "hard" mask (i.e. with alpha values zero or one) we can "clip" the layer so that it attains a well-defined shape of our choosing.
We could use an external image to define our mask. However, since our mask has a specific shape (rectangle with some corners rounded), there's a better way to do it in code. To specify a shape for our mask, we use a subclass of CALayer
called CAShapeLayer
. CAShapeLayer
s are layers that can have any shape defined by a vector path of the Core Graphics opaque type CGPathRef
. We can either directly create this path using the C-based Core Graphics API, or - more conveniently - we can create a UIBezierPath
object with the Objective-C UIKit framework. UIBezierPath
exposes the underlying CGPathRef
object via its CGPath
property which can be assigned to our CAShapeLayer
's path
property, and this shape layer can in turn be assigned to be our CALayer
's mask. Luckily for us, UIBezierPath
can be initialized with many interesting predefined shapes, including a rectangle with rounded corners (where we choose which corner(s) to round).
Add the following code, after, say, the line rightPage.transform = makePerspectiveTransform();
in the ViewController.m viewDidAppear:
method:
// rounding corners UIBezierPath *leftPageRoundedCornersPath = [UIBezierPath bezierPathWithRoundedRect:leftPage.bounds byRoundingCorners:UIRectCornerTopLeft|UIRectCornerBottomLeft cornerRadii:CGSizeMake(25., 25.0)]; UIBezierPath *rightPageRoundedCornersPath = [UIBezierPath bezierPathWithRoundedRect:rightPage.bounds byRoundingCorners:UIRectCornerTopRight|UIRectCornerBottomRight cornerRadii:CGSizeMake(25.0, 25.0)]; CAShapeLayer *leftPageRoundedCornersMask = [CAShapeLayer layer]; CAShapeLayer *rightPageRoundedCornersMask = [CAShapeLayer layer]; leftPageRoundedCornersMask.frame = leftPage.bounds; rightPageRoundedCornersMask.frame = rightPage.bounds; leftPageRoundedCornersMask.path = leftPageRoundedCornersPath.CGPath; rightPageRoundedCornersMask.path = rightPageRoundedCornersPath.CGPath; leftPage.mask = leftPageRoundedCornersMask; rightPage.mask = rightPageRoundedCornersMask;
The code should be self explanatory. The bezier path is in the shape of a rectangle with the same size as the layer being masked, and has the appropriate corners rounded off (top left and bottom left for the left page, top right and bottom right for the right page).
Build the project and run. The page corners should be rounded now...cool!
Applying a Shadow
Applying a shadow is also easy, but there's a hitch when we want a shadow after we apply a mask (like we just did). We'll run into this in due course!
A CALayer has a shadowPath
which is a (you guessed it) CGPathRef
and defines the shape of the shadow. A shadow has several properties we can set: its colour, its offset (basically which way and how far away it falls from the layer), its radius (specifying its extent and blurriness), and its opacity.
shadowPath
as the drawing system will work out the shadow from the layer's composited alpha channel. However, this is less efficient and will usually cause performance to suffer, so it is always recommended to set the shadowPath
property when possible.
Insert the following block of code immediately after the one we just added:
leftPage.shadowPath = [UIBezierPath bezierPathWithRect:leftPage.bounds].CGPath; rightPage.shadowPath = [UIBezierPath bezierPathWithRect:rightPage.bounds].CGPath; leftPage.shadowRadius = 100.0; leftPage.shadowColor = [UIColor blackColor].CGColor; leftPage.shadowOpacity = 0.9; rightPage.shadowRadius = 100.0; rightPage.shadowColor = [UIColor blackColor].CGColor; rightPage.shadowOpacity = 0.9;
Build and run the code. Unfortunately, no shadow will be cast and the output will look exactly as it did previously. What's up with that?!
To understand what's going on, comment out the first block of code we wrote in this tutorial, but leave the shadow code in place. Now build and run. OK, we've lost the rounded corners, but now our layers cast a nebulous shadow on the view behind them, enhancing the sense of depth.
What happens is that when we set a CALayer
's mask property, the layer's render region is clipped to the mask region. Therefore shadows (which are naturally cast away from the layer) do not get rendered and hence do not appear.
Before we attempt to solve this problem, note that the shadow for the right page got cast on top of the left page. This is because the leftPage
was added to the view before rightPage
, therefore the former is effectively "behind" the latter in the drawing order (even though they're both sibling layers). Besides switching the order in which the two layers were added to the super layer, we could change the zPosition
property of the layers to explicitly specify the drawing order, assigning a smaller float value to the layer we wanted to be drawn first. We'd be in for a more complex implementation if we wanted to eschew this effect altogether, but since it (fortuitiously) lends a nice shaded effect to our page, we're happy with things the way they are!
Getting Both Shadows and a Masked Shape
To solve this problem, we'll use two layers to represent each page, one to generate shadows and the other to display the drawn content in a shaped region. In terms of the heirarchy, we'll add the shadow layers as a direct sublayer to the background view's layer. Then we'll add the content layers to the shadow layers. Hence the shadow layers will double as "containers" for the content layer. All our geometric transforms (to do with the page turning effect) will be applied to the shadow layers. Since the content layers will be rendered relative to their containers, we won't have to apply any transforms to them.
Once you've understood this, then writing the code is relatively straightforward. But because of all the changes we're making it'll be messy to modify the previous code, therefore I suggest you replace all the code in viewDidAppear:
with the following:
- (void)viewDidAppear:(BOOL)animated { [super viewDidAppear:animated]; self.view.backgroundColor = [UIColor whiteColor]; leftPageShadowLayer = [CAShapeLayer layer]; rightPageShadowLayer = [CAShapeLayer layer]; leftPageShadowLayer.anchorPoint = CGPointMake(1.0, 0.5); rightPageShadowLayer.anchorPoint = CGPointMake(0.0, 0.5); leftPageShadowLayer.position = CGPointMake(self.view.bounds.size.width/2, self.view.bounds.size.height/2); rightPageShadowLayer.position = CGPointMake(self.view.bounds.size.width/2, self.view.bounds.size.height/2); leftPageShadowLayer.bounds = CGRectMake(0, 0, self.view.bounds.size.width/2, self.view.bounds.size.height); rightPageShadowLayer.bounds = CGRectMake(0, 0, self.view.bounds.size.width/2, self.view.bounds.size.height); UIBezierPath *leftPageRoundedCornersPath = [UIBezierPath bezierPathWithRoundedRect:leftPageShadowLayer.bounds byRoundingCorners:UIRectCornerTopLeft|UIRectCornerBottomLeft cornerRadii:CGSizeMake(25., 25.0)]; UIBezierPath *rightPageRoundedCornersPath = [UIBezierPath bezierPathWithRoundedRect:rightPageShadowLayer.bounds byRoundingCorners:UIRectCornerTopRight|UIRectCornerBottomRight cornerRadii:CGSizeMake(25.0, 25.0)]; leftPageShadowLayer.shadowPath = leftPageRoundedCornersPath.CGPath; rightPageShadowLayer.shadowPath = rightPageRoundedCornersPath.CGPath; leftPageShadowLayer.shadowColor = [UIColor blackColor].CGColor; leftPageShadowLayer.shadowRadius = 100.0; leftPageShadowLayer.shadowOpacity = 0.9; rightPageShadowLayer.shadowColor = [UIColor blackColor].CGColor; rightPageShadowLayer.shadowRadius = 100; rightPageShadowLayer.shadowOpacity = 0.9; leftPage = [CALayer layer]; rightPage = [CALayer layer]; leftPage.frame = leftPageShadowLayer.bounds; rightPage.frame = rightPageShadowLayer.bounds; leftPage.backgroundColor = [UIColor whiteColor].CGColor; rightPage.backgroundColor = [UIColor whiteColor].CGColor; leftPage.borderColor = [UIColor darkGrayColor].CGColor; rightPage.borderColor = [UIColor darkGrayColor].CGColor; leftPage.transform = makePerspectiveTransform(); rightPage.transform = makePerspectiveTransform(); CAShapeLayer *leftPageRoundedCornersMask = [CAShapeLayer layer]; CAShapeLayer *rightPageRoundedCornersMask = [CAShapeLayer layer]; leftPageRoundedCornersMask.frame = leftPage.bounds; rightPageRoundedCornersMask.frame = rightPage.bounds; leftPageRoundedCornersMask.path = leftPageRoundedCornersPath.CGPath; rightPageRoundedCornersMask.path = rightPageRoundedCornersPath.CGPath; leftPage.mask = leftPageRoundedCornersMask; rightPage.mask = rightPageRoundedCornersMask; leftPageShadowLayer.transform = makePerspectiveTransform(); rightPageShadowLayer.transform = makePerspectiveTransform(); curtainView = [[UIView alloc] initWithFrame:self.view.bounds]; curtainView.backgroundColor = [UIColor scrollViewTexturedBackgroundColor]; [curtainView.layer addSublayer:leftPageShadowLayer]; [curtainView.layer addSublayer:rightPageShadowLayer]; [leftPageShadowLayer addSublayer:leftPage]; [rightPageShadowLayer addSublayer:rightPage]; UITapGestureRecognizer *foldTap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(fold:)]; [self.view addGestureRecognizer:foldTap]; UITapGestureRecognizer *unfoldTap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(unfold:)]; unfoldTap.numberOfTouchesRequired = 2; [self.view addGestureRecognizer:unfoldTap]; }
You also need to add the two instance variables corresponding to the two shadow layers. Modify the code at the beginning of the @implementation
section to read:
@implementation ViewController { CALayer *leftPage; CALayer *rightPage; UIView *curtainView; CAShapeLayer *leftPageShadowLayer; CAShapeLayer *rightPageShadowLayer; }
Based on our previous discussion, you should find the code straightforward to follow.
Recall that I previously mentioned that the layers containing the drawn content would be contained as sublayers to the shadow generating layer. Therefore, we need to modify our fold:
and unfold:
methods to perform the requisite transformation on the shadow layers.
- (void)fold:(UITapGestureRecognizer *)gr { // drawing the "incrementalImage" bitmap into our layers CGImageRef imgRef = ((CanvasView *)self.view).incrementalImage.CGImage; leftPage.contents = (__bridge id)imgRef; rightPage.contents = (__bridge id)imgRef; leftPage.contentsRect = CGRectMake(0.0, 0.0, 0.5, 1.0); // this rectangle represents the left half of the image rightPage.contentsRect = CGRectMake(0.5, 0.0, 0.5, 1.0); // this rectangle represents the right half of the image leftPageShadowLayer.transform = CATransform3DScale(leftPageShadowLayer.transform, 0.95, 0.95, 0.95); rightPageShadowLayer.transform = CATransform3DScale(rightPageShadowLayer.transform, 0.95, 0.95, 0.95); leftPageShadowLayer.transform = CATransform3DRotate(leftPageShadowLayer.transform, D2R(7.5), 0.0, 1.0, 0.0); rightPageShadowLayer.transform = CATransform3DRotate(rightPageShadowLayer.transform, D2R(-7.5), 0.0, 1.0, 0.0); [self.view addSubview:curtainView]; } - (void)unfold:(UITapGestureRecognizer *)gr { leftPageShadowLayer.transform = CATransform3DIdentity; rightPageShadowLayer.transform = CATransform3DIdentity; leftPageShadowLayer.transform = makePerspectiveTransform(); // uncomment later rightPageShadowLayer.transform = makePerspectiveTransform(); // uncomment later [curtainView removeFromSuperview]; }
Build and run the app to check out our pages with both rounded corners and shadow!
As before, one-finger taps cause the book to fold up in increments, while a two finger tap restores removes the effect and restores the app to its normal drawing mode.
Incorporating Pinch-Based Folding
Things are looking good, visually speaking, but the tap isn't really a realistic gesture for a book folding metaphor. If we think about iPad apps like Paper, the folding and unfolding is driven by a pinch gesture. Let's implement that now!
Implement the following method in ViewController.m:
- (void)foldWithPinch:(UIPinchGestureRecognizer *)p { if (p.state == UIGestureRecognizerStateBegan) // ............... (A) { self.view.userInteractionEnabled = NO; CGImageRef imgRef = ((CanvasView *)self.view).incrementalImage.CGImage; leftPage.contents = (__bridge id)imgRef; rightPage.contents = (__bridge id)imgRef; leftPage.contentsRect = CGRectMake(0.0, 0.0, 0.5, 1.0); rightPage.contentsRect = CGRectMake(0.5, 0.0, 0.5, 1.0); leftPageShadowLayer.transform = CATransform3DIdentity; rightPageShadowLayer.transform = CATransform3DIdentity; leftPageShadowLayer.transform = makePerspectiveTransform(); rightPageShadowLayer.transform = makePerspectiveTransform(); [self.view addSubview:curtainView]; } float scale = p.scale > 0.48 ? p.scale : 0.48; // .......................... (B) scale = scale < 1.0 ? scale : 1.0; // SOME CODE WILL GO HERE (1) leftPageShadowLayer.transform = CATransform3DIdentity; rightPageShadowLayer.transform = CATransform3DIdentity; leftPageShadowLayer.transform = makePerspectiveTransform(); rightPageShadowLayer.transform = makePerspectiveTransform(); leftPageShadowLayer.transform = CATransform3DScale(leftPageShadowLayer.transform, scale, scale, scale); // (C) rightPageShadowLayer.transform = CATransform3DScale(rightPageShadowLayer.transform, scale, scale, scale); leftPageShadowLayer.transform = CATransform3DRotate(leftPageShadowLayer.transform, (1.0 - scale) * M_PI, 0.0, 1.0, 0.0); rightPageShadowLayer.transform = CATransform3DRotate(rightPageShadowLayer.transform, -(1.0 - scale) * M_PI, 0.0, 1.0, 0.0); // SOME CODE WILL GO HERE (2) if (p.state == UIGestureRecognizerStateEnded) // ........................... (C) { // SOME CODE CHANGES HERE LATER (3) self.view.userInteractionEnabled = YES; [curtainView removeFromSuperview]; } }
A brief explanation regarding the code, with respect to the labels A, B, and C referred in the code:
- When the pinch gesture is recognized (indicated by its
state
property taking the valueUIGestureRecognizerStateBegan
) we start preparing for the fold animation. The statementself.view.userInteractionEnabled = NO;
ensures that the any additional touches that take place during the pinch gesture won't cause drawing to take place on the canvas view. The remaining code should be familiar to you. We're just resetting the layer transforms. - The
scale
property of the pinch determines the ratio of the distance between the fingers with respect to the start of the pinch. I decided to clamp the value we'll use to calculate our pages' scaling and rotation transforms between0.48 . The condition
scale is so that a "reverse pinch" (the user moving his fingers further apart than the start of the pinch, corresponding to
p.scale > 1.0
) has no effect. The conditionp.scale > 0.48
is so that when the inter-finger distance becomes approximately half of what it was at the start of the pinch, our folding animation is completed and any further pinch has no effect. I choose 0.48 instead of 0.50 because of the way I calculate the turning angle of the layer's rotational transform. With a value of 0.48 the rotation angle will be slightly less than 90 degrees, so the book won't completely fold and hence won't become invisible. - After the user ends the pinch, we remove the view presenting our animated layers from the canvas view (as before), and we restore the canvas' interactivity.
Add the code to add a pinch recognizer at the end of viewDidAppear:
UIPinchGestureRecognizer *pinch = [[UIPinchGestureRecognizer alloc] initWithTarget:self action:@selector(foldWithPinch:)]; [self.view addGestureRecognizer:pinch];
You may get rid of all the code related to the two tap recognizers from ViewController.m because we won't be needing those anymore.
Build and run. If you're testing on the simulator instead of the device, remember that you need to hold down the option key in order to simulate two finger gestures such as pinching.
In principle, our pinch-based folding-unfolding is working, but you're bound to notice (particularly if you're testing on a physical device) that the animation is slow and lags behind the pinch, therefore weakening the illusion that the pinch is driving the folding-unfolding animation. So what's going on?
Getting The Animations Right
Remember the implicit animation of the transform that we were enjoying "for free" when the animation was controlled by the tap? Well, it turns out that the same implicit animation has become a hindrance now! Generally, when we want to control an animation by a gesture, such as a view being dragged across the screen with a dragging (panning) gesture (or the case with our app here) we want to cancel implicit animations. Let's make sure we understand why, considering the case of a user dragging a view across the screen with his finger:
- The
view
is at positionp0
to begin with (i.e.view.position = p0
where p0 = (x0, y0) and is aCGPoint
) - When UIKit next samples the user's touch on the screen, he's dragged his finger to another position, say
p1
. - Implicit animation kicks in, causing the animation system begins to animate the position change of
view
fromp0
top1
with the "relaxed" duration of 0.25 seconds. However, the animation has barely started, and the user has already dragged his finger to a new position,p2
. The animation has to be canceled and a new one begun towards position p2. - ...and so on and so forth!
Since the user's panning gesture (in our hypothetical example) is effectively a continuous one, we just need to change the position of the view in step with the user's gesture to maintain the illusion of a response animation! Exactly the same argument applies for our page fold with pinch situation here. I only mentioned the dragging example because it seemed simpler to explain what was going on.
There are different ways to disable implicit animations. We'll choose the simplest one, which involves wrapping our code in a CATransaction
block and invoking the class method [CATransaction setDisableActions:YES]
. So, what's a CATransaction
anyway? In simplest terms, CATransaction
"bundles up" property changes that need to be animated and successively updates their values on the screen, handling all the timing aspects. In effect, it does all the hard work related to rendering our animations onto the screen for us! Even though we haven't explicitly used an animation transaction yet, an implicit one is always present when any animation related code is executed. What we need to do now is to wrap up the animation with a custom CATransaction
block.
In the pinchToFold:
method, add these lines:
[CATransaction begin]; [CATransaction setDisableActions:YES];
At the site of the comment // SOME CODE WILL GO HERE (1)
,
add the line:
[CATransaction commit];
If you build and run the app now, you'll notice that the folding-unfolding is much more fluid and responsive!
Another animation-related issue that we need to tackle is that our unfolding:
method doesn't animate the restoration of the book to its flattened state when the pinch gesture ends. You may complain that in our code we haven't actually bothered reverting the transform
in the "then" block of our if (p.state == UIGestureRecognizerStateEnded)
statement. But you're welcome to try inserting the statements that reset the layer's transform before the statement [canvasView removeFromSuperview];
to see whether the layers animate this property change (spoiler: they won't!).
The reason the layers won't animate the property change is that any property change that we might carry out in that block of code would be lumped in the same (implicit) CATransaction
as the code to remove the layer hosting view (canvasView
) from the screen. The view removal would happen immediately - it's not an animated change, after all - and no animation in any subviews (or sublayers added to its layer) would occur.
Again, an explicit CATransaction
block comes to our rescue! A CATransaction
has a completion block which is only executed after any property changes that appear after it have finished animating.
Change the code following the if (p.state == UIGestureRecognizerStateEnded)
clause, so that the if statement reads as follows:
if (p.state == UIGestureRecognizerStateEnded) { [CATransaction begin]; [CATransaction setCompletionBlock:^{ self.view.userInteractionEnabled = YES; [curtainView removeFromSuperview]; }]; [CATransaction setAnimationDuration:0.5/scale]; leftPageShadowLayer.transform = CATransform3DIdentity; rightPageShadowLayer.transform = CATransform3DIdentity; [CATransaction commit]; }
Note that I decided to make the animation duration change inversely with respect to the scale
so that the greater the degree of the fold at the time the gesture ends the more time the reverting animation should take.
The crucial thing to understand here is that only after the transform
changes have animated does the code in the completionBlock
execute. The CATransaction
class has other properties you can use the configure an animation exactly how you want it. I suggest you take a look at the documentation for more.
Build and run. Finally, our animation not only looks good, but responds properly to user interaction!
Conclusion
I hope this tutorial has convinced you that Core Animation Layers are a realistic choice for achieving fairly sophisticated 3D effects and animations with little effort. With some optimization, you ought to be able to animate a few hundred layers on the screen at the same time if you need to. A great use case for Core Animation is for incorporating a cool 3D transition when switching from one view to another in your app. I also feel that Core Animation might be a viable option for building simple word-based or card-based games.
Even though our tutorial spanned two parts, we've barely scratched the surface of layers and animations (but hopefully that's a good thing!). There are other kinds of interesting CALayer
subclasses that we didn't get a chance to look at. Animation is a huge topic in itself. I recommend watching the WWDC talks on these topics, such as "Core Animation in Practice" (Sessions 424 and 424, WWDC 2010), "Core Animation Essentials" (Session 421, WWDC 2011), "iOS App Performance - Graphics and Animations" (Session 238, WWDC 2012) and "Optimizing 2D Graphics and Animation Performance" (Session 506, WWDC 2012), and then digging into the documentation. Happy learning and app writing!
Comments