Advertisement
iOS SDK

Create a 3D Page Folding Animation: Polishing the Page Fold

by

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 CALayers 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 UIViews), 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? CALayers 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.

Effect of masking a layer with a soft vs. a hard mask

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. CAShapeLayers 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!

Before and after rounding

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.

Actually it should be mentioned that it is not absolutely essential to set the 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.

We can't get shadows if we use a mask

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!

Both shadows and rounded corners

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:

  1. When the pinch gesture is recognized (indicated by its state property taking the value UIGestureRecognizerStateBegan) we start preparing for the fold animation. The statement self.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.
  2. 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 between 0.48 < p.scale < 1.0. The condition scale < 1.0 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 condition p.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.
  3. 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:

  1. The view is at position p0 to begin with (i.e. view.position = p0 where p0 = (x0, y0) and is a CGPoint)
  2. When UIKit next samples the user's touch on the screen, he's dragged his finger to another position, say p1.
  3. Implicit animation kicks in, causing the animation system begins to animate the position change of view from p0 to p1 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.
  4. ...and so on and so forth!
Implicit animations getting in the way

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!

Related Posts