This two part mini-series will teach you how to create an impressive page-folding effect with Core Animation. In this installment, you'll first learn how to create a sketch pad view and then how to apply a basic spine folding animation on that view. Read on!
Final Project Demo
Tutorial Overview
Working with the UIView
class is central to iOS SDK development. Views have both a visual aspect (i.e. what you see on the screen) and usually a control aspect (user interaction via touches and gestures). The visual aspect is actually handled by a class belonging to the Core Animation Framework, called CALayer
(which in turn is implemented via OpenGL, only we don't care to go down to that level of abstraction here). Layer objects are instances of the CALayer
class or one of its subclasses. Without delving too deep into the theory (this is, after all, supposed to be a hands-on tutorial!) we should keep the following points in mind:
- Every view in iOS is backed by a layer, which is responsible for its visual content. Programmatically, this is accessed as the layer property on the view.
- There is a parallel layer hierarchy corresponding to the view hierarchy on screen. This means that if (say) a label is a subview to a button, then the label's layer is the sublayer to the button's layer. Strictly speaking, this parallelism holds only as long as we don't add our own sublayers to the mix.
- Several properties that we set on views (in particular, the views related to appearance) are in reality properties on the underlying layer. However, the layer exposes some properties that aren't available at the view level. In that sense, layers are more powerful than views.
- Compared to views, layers are more "lightweight" objects. Therefore, if for some aspect of our app we require visualization without interactivity, then layers are probably the more performant option.
- A
CALayer
's content consists of a bitmap. While the base class is quite useful as it is, it also has several important subclasses in Core Animation. Notably, there isCAShapeLayer
which allows us to represent shapes by means of vector paths.
So, what can we achieve with layers that we can't easily do with views directly? 3D, for one, which is what we'll focus on here. CALayer
's capabilities put fairly sophisticated 3D effects and animations within your reach, without having to descend to the OpenGL level. This is the goal behind this tutorial: to demonstrate an interesting 3D effect that gives a small taste of what we can achieve with CALayer
s.
Our Objective
We have a drawing app that lets a user draw on the screen with his finger (for which I'll reuse the code from a previous tutorial of mine). If the user then performs a pinch gesture on the drawing canvas, it folds up along a vertical line running along the center of the canvas (much like the spine of a book). The folded up book even casts a shadow on the background.
The drawing part of the app that I'm borrowing isn't really important here, and we could very well use any image to demonstrate the folding effect. However, the effect makes for a very nice visual metaphor in the context of a drawing app where the pinching action exposes a book with several leaves (containing our previous drawings) that we can browse. This metaphor is notably seen in the Paper app. While for the purposes of the tutorial our implementation is going to be simpler and less sophisticated, but it isn't too far off ...and of course, you can take what you learn in this tutorial and make it even better!
Layer Geometry In Brief
Recall that in the iOS coordinate system the origin lies at the top-left corner of the screen, with the x-axis increasing towards the right and the y-axis downward. A view's frame describes its rectangle in its superview's coordinate system. Layers can be queried for their frame as well, but there's another (preferred) way of describing a layer's location and size. We'll motivate these with a simple example: imagine two layers, A and B, as rectangular pieces of paper. You'd like to make the layer B a sublayer of A, so you fix B atop A using a pin, keeping the straight sides in parallel. The pin passes through two points, one in A and one in B. Knowing the location of these two points gives us an effective way of describing the position of B relative to A. We'll label the point the pin pierces in A the "anchor point" and the point in B "position". Take a look at the following figure, for which we'll do the math:
The figure looks like it has a lot going on, but not to worry, we'll examine it bit by bit:
- The upper graphic shows the hierarchical relationship: the purple layer (A, from our previous discussion) is made a sublayer of the blue layer (B). The green circle with the plus is where A is pinned in B. The position (in A's coordinate system) is given as {32.5, 62.5}.
- Now turn your attention to the lower graphic. The anchor point is specified differently. It's relative to the size of layer B, such that the upper-left corner in {0.0, 0.0} and the bottom-right corner in {1.0, 1.0}. Since our pin is one-fourth the distance across B's width and one-half the way down, the anchor point is {0.25, 0.5}.
- Knowing the size of B (50 x 45) we can now compute the coordinate of the upper-left corner. Relative to B's upper-left corner, the anchor point is 0.25 x 50 = 12.5 points in the x-direction and 0.50 x 45 = 22.5 point in the y-direction. Subtract these from the position's coordinates, and you get the coordinates of B's origin in A's system: {32.5 - 12.5, 62.5 - 22.5} = {20, 40}. Clearly, B's frame is {20, 40, 50, 45}.
The calculation is quite straightforward, so make sure you understand it thoroughly. It'll give you a good feel of the relationship between position, anchor point, and frame.
The anchor point is quite significant because when we perform 3D transformations on the layer, these transformations are performed with respect to the anchor point. We'll talk about that next (and then you'll see some code, I promise!).
Layer Transformations
I'm sure you're familiar with the concept of transforms such as scaling, translation, and rotation. In the Photos app in iOS 6, if you pinch with two fingers to zoom in or out on a photo in your album, you're performing a scale transform. If you do a turning motion with your two fingers, the photo rotates, and if you drag it while keeping the sides parallel, that's translation. Core animation and CALayer
trump UIView
by allowing you to perform transforms in 3D instead of just 2D. Of course, in 2013 our iDevice screens are still 2D, so 3D transforms employ some geometric trickery to fool our eyes into interpretting a flat image as a 3D object (the process is no different than depicting a 3D object in a line drawing made with a pencil, really). To deal with the 3rd dimension, we need to use a z-axis that we imagine piercing our device screen and perpendicular to it.
The anchor point is important because the precise result of the same transform applied will usually differ depending on it. This is especially important - and most easily understood - with a rotation transform through the same angle applied with respect to two different anchor points in the rectangle in the figure below (the red dot and the blue dot). Note that the rotation is in the plane of the image (or about the z-axis, if you'd prefer to think of it that way).
The Page Fold Animation
So, how do we implement the folding effect we're after? While layers are really cool, you can't fold them across the middle! The solution is - as I'm sure you've figured out - to use two layers, one for each page on either side of the fold. Based on what we discussed previously, let's work out the geometric properties of these two layers in advance:
- We've chosen a point along the "fold spine" to be our anchor point for both our layers, because that's where our fold (i.e. the rotation transform) happens. The rotation takes place about a vertical line (i.e. the y-axis) - make sure you visualize this. That's fine, you might say, but why did I choose the midpoint of the spine (instead of say, a point at the bottom or the top)? Actually, in this particular instance, it doesn't make a difference as far as rotation is concerned. But we also want to do a scale transform (making the layers slightly smaller as they fold) and keeping the anchor point at the middle means the book will stay nice and centered when folding. That's because for scaling, the point coinciding with the anchor point remains fixed in position.
- The anchor point for the first layer is
{1.0, 0.5}
and for the second layer is{0.0, 0.5}
, in their respective coordinate spaces. Make sure you confirm that from the figure before proceeding! - The point lying below the anchor point in the superlayer (i.e. the "position") is the midpoint, so it's coordinates are
{width/2, height/2}
. Remember that the position property is in standard coordinates, not normalized. - The size of each of the layers is
{width/2, height}
.
Implementation
We now know enough to write some code!
Create a new Xcode project with the "Empty Application" template, and call it LayerFunTut. Make it an iPad app, and enable Automatic Reference Counting (ARC), but disable the options for Core Data and Unit Tests. Save it.
In the Target > Summary page that shows up, scroll down to "Supported Interface Orientations" and choose the two landscape orientations.
Scroll further down until you get to "Linked Frameworks and Libraries", click on "+" and add the QuartzCore core framework, which is required for Core Animation and CALayers.
We'll start by incorporating our drawing app into the project. Create a new Objective-C class called CanvasView
, making it a subclass of UIView
. Paste the following code into CanvasView.h:
// // CanvasView.h // #import <UIKit/UIKit.h> @interface CanvasView : UIView @property (nonatomic, strong) UIImage *incrementalImage; @end
And then into CanvasView.m:
// // CanvasView.m // #import "CanvasView.h" @implementation CanvasView { UIBezierPath *path; CGPoint pts[5]; uint ctr; } - (id)initWithCoder:(NSCoder *)aDecoder { if (self = [super initWithCoder:aDecoder]) { self.backgroundColor = [UIColor clearColor]; [self setMultipleTouchEnabled:NO]; path = [UIBezierPath bezierPath]; [path setLineWidth:6.0]; } return self; } - (id)initWithFrame:(CGRect)frame { self = [super initWithFrame:frame]; if (self) { self.backgroundColor = [UIColor clearColor]; [self setMultipleTouchEnabled:NO]; path = [UIBezierPath bezierPath]; [path setLineWidth:6.0]; } return self; } - (void)drawRect:(CGRect)rect { [self.incrementalImage drawInRect:rect]; [[UIColor blueColor] setStroke]; [path stroke]; } - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { ctr = 0; UITouch *touch = [touches anyObject]; pts[0] = [touch locationInView:self]; } - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event { UITouch *touch = [touches anyObject]; CGPoint p = [touch locationInView:self]; ctr++; pts[ctr] = p; if (ctr == 4) { pts[3] = CGPointMake((pts[2].x + pts[4].x)/2.0, (pts[2].y + pts[4].y)/2.0); [path moveToPoint:pts[0]]; [path addCurveToPoint:pts[3] controlPoint1:pts[1] controlPoint2:pts[2]]; [self setNeedsDisplay]; pts[0] = pts[3]; pts[1] = pts[4]; ctr = 1; } } - (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event { [self drawBitmap]; [self setNeedsDisplay]; [path removeAllPoints]; ctr = 0; } - (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event { [self touchesEnded:touches withEvent:event]; } - (void)drawBitmap { UIGraphicsBeginImageContextWithOptions(self.bounds.size, NO, 0.0); if (!self.incrementalImage) { UIBezierPath *rectpath = [UIBezierPath bezierPathWithRect:self.bounds]; [[UIColor clearColor] setFill]; [rectpath fill]; } [self.incrementalImage drawAtPoint:CGPointZero]; [[UIColor blueColor] setStroke]; [path stroke]; self.incrementalImage = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); } @end
As previously mentioned, this is just the code from another tutorial I wrote (with some minor modifications). Be sure to check it out if you're not sure how the code works. For the purposes of this tutorial though, what is important is that CanvasView
allows the user to draw smooth strokes on the screen. We've declared a property called incrementalImage
which stores a bitmap version of the user's drawing. This is the image we'll be "folding up" with CALayer
s.
Time to write the view controller code and implement the ideas we worked out previously. One thing we haven't discussed is how we get the drawn image into our CALayer
such that half of the image gets drawn into the left page and the other half into the right page. Luckily, that's only a few lines of code, which I'll talk about later.
Create a new Objective-C class called ViewController
, make it a subclass of UIViewController
, and don't check any of the options that show up.
Paste the following code into ViewController.m
// // ViewController.m // #import "ViewController.h" #import "CanvasView.h" #import "QuartzCore/QuartzCore.h" #define D2R(x) (x * (M_PI/180.0)) // macro to convert degrees to radians @interface ViewController () @end @implementation ViewController { CALayer *leftPage; CALayer *rightPage; UIView *curtainView; } - (void)loadView { self.view = [[CanvasView alloc] initWithFrame:[[UIScreen mainScreen] applicationFrame]]; } - (void)viewDidLoad { [super viewDidLoad]; self.view.backgroundColor = [UIColor blackColor]; } - (void)viewDidAppear:(BOOL)animated { [super viewDidAppear:animated]; self.view.backgroundColor = [UIColor whiteColor]; CGSize size = self.view.bounds.size; leftPage = [CALayer layer]; rightPage = [CALayer layer]; leftPage.anchorPoint = (CGPoint){1.0, 0.5}; rightPage.anchorPoint = (CGPoint){0.0, 0.5}; leftPage.position = (CGPoint){size.width/2.0, size.height/2.0}; rightPage.position = (CGPoint){size.width/2.0, size.height/2.0}; leftPage.bounds = (CGRect){0, 0, size.width/2.0, size.height}; rightPage.bounds = (CGRect){0, 0, size.width/2.0, size.height}; leftPage.backgroundColor = [UIColor whiteColor].CGColor; rightPage.backgroundColor = [UIColor whiteColor].CGColor; leftPage.borderWidth = 2.0; // borders added for now, so we can visually distinguish between the left and right pages rightPage.borderWidth = 2.0; leftPage.borderColor = [UIColor darkGrayColor].CGColor; rightPage.borderColor = [UIColor darkGrayColor].CGColor; //leftPage.transform = makePerspectiveTransform(); // uncomment later //rightPage.transform = makePerspectiveTransform(); // uncomment later curtainView = [[UIView alloc] initWithFrame:self.view.bounds]; curtainView.backgroundColor = [UIColor scrollViewTexturedBackgroundColor]; [curtainView.layer addSublayer:leftPage]; [curtainView.layer 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]; } - (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 leftPage.transform = CATransform3DScale(leftPage.transform, 0.95, 0.95, 0.95); rightPage.transform = CATransform3DScale(rightPage.transform, 0.95, 0.95, 0.95); leftPage.transform = CATransform3DRotate(leftPage.transform, D2R(7.5), 0.0, 1.0, 0.0); rightPage.transform = CATransform3DRotate(rightPage.transform, D2R(-7.5), 0.0, 1.0, 0.0); [self.view addSubview:curtainView]; } - (void)unfold:(UITapGestureRecognizer *)gr { leftPage.transform = CATransform3DIdentity; rightPage.transform = CATransform3DIdentity; // leftPage.transform = makePerspectiveTransform(); // uncomment later // rightPage.transform = makePerspectiveTransform(); // uncomment later [curtainView removeFromSuperview]; } // UNCOMMENT LATER: /* CATransform3D makePerspectiveTransform() { CATransform3D transform = CATransform3DIdentity; transform.m34 = 1.0 / -2000; return transform; } */ @end
Ignoring the commented out code for now, you can see that the layer set up is exactly as we planned out above.
Let's discuss this code briefly:
- We decide to override the -viewDidAppear: method instead of -viewDidLoad (which you might be more used to) because when the latter method is called the bounds of the view are still for portrait mode - but our app runs in landscape mode. By the time viewDidAppear: is called, the bounds have been set correctly and so we put our code there (we've temporarily added thick borders so we can make out the left and right layers as we apply transformations on them).
- We added a gesture recognizer that registers a tap, and for each tap it causes the pages to become slightly smaller (95% of their previous size) and makes them turn by 7.5 degrees. The signs are different because one of the pages turns clockwise while the other turns counterclockwise. We would have to go into the math to see which sign corresponds to which direction, but since there are only two options, it's easier just to write the code and check! By the way, the transform functions accept angles in radians, so we use the macro
D2R()
to convert from radians to degrees. One important observation is that the functions that take a transform in their argument (such asCATransform3DScale
andCATransform3DRotate
) "chain together" one transform with another (the current value of the layer transform property). Other functions, such asCATransform3DMakeRotation
,CATransform3DMakeScale
,CATransform3DIdentity
just construct the appropriate transformation matrix.CATransform3DIdentity
is the "identity transform" that a layer has when you create one. It's analagous to the number "1" in a multiplication in that applying an identity transform to a layer leaves its transform unchanged, much like multiplying a number by one. - Regarding the drawing, we set the contents property of our layers to be the image. It's quite important to note that we set the content rectangle (normalized between 0 and 1 along each dimension) such that each page only displays half of the image corresponding to it. This normalized coordinate system is the same as the one we discussed previously when talking about the anchor point, so you should be able to work out the values we used for each half of the image.
- The curtainView object simply acts as a container for the page layers (more precisely, they are made sublayers to curtainView's underlying layer). Remember that we already computed the placement and geometry of the layers, and these are with respect curtainView's layer. Tapping once adds this view on top of our canvas view and applies the transform on the layer. Double tapping removes it, to reveal the canvas once more, as well as reverts the transform of the layers to the identity transform.
- Note the use of
CGImage
here - and alsoCGColor
earlier - instead ofUIImage
andUIColor
. This is becauseCALayer
operates on a level below UIKit, and it works with "opaque" data types (roughly meaning, don't ask about their underlying implementation!) which are defined in the Core Graphics framework. Objective-C classes likeUIColor
andUIImage
can be thought of as object oriented wrappers around their more primitive CG versions. For convenience sake, many UIKit objects expose their underlying CG type as a property.
In the AppDelegate.m file, replace all the code with the following (the only thing we've added is to include the ViewController header file and to make a ViewController
instance the root view controller):
// // AppDelegate.m // #import "AppDelegate.h" #import "ViewController.h" @implementation AppDelegate - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]]; self.window.rootViewController = [[ViewController alloc] init]; self.window.backgroundColor = [UIColor whiteColor]; [self.window makeKeyAndVisible]; return YES; } @end
Build the project and run it on the simulator or on your device. Scribble on the canvas for a bit, and then tap on the screen with a single finger to trigger the gesture recognizer action (tapping with a two finger causes the 3D effect to end and the drawing canvas to reappear).
Not quite the effect we're going for! What's going on?
Getting Our Perspective Right
First, notice that the pages get smaller with each single tap, so the issue doesn't lie with the scaling transform, only with the rotation. The problem is that even though the rotation is happening in a (mathematical) 3D space, the result is being projected onto our flat screens pretty much the same way as a 3D object casts its shadow on a wall. To convey depth, we need to use some kind of cue. The most important cue is that of perspective: an object closer to our eyes appears larger than one further away. Shadows are another great cue, and we'll get to them shortly. So, how do we incorporate perspective into our transform?
Let's talk a little bit about transforms first. What are they, really? Mathematically speaking, you ought to know that if we represent the points in our shape as mathematical vectors, then geometric transforms such as scale, rotation, and translation are represented as matrix transformations. What this means is that if we take a matrix representing a transformation and multiply with a vector that represents a point in our shape, then the result of the multiplication (also a vector) represents where that point ends up after being transformed. We can't say much more here without diving into the theory (which is really worth learning about, if you're not already familiar with it - especially if you intend to incorporate cool 3D effects in your apps!).
What about code? Previously, we set the layer geometry by setting its anchorPoint
, position
, and bounds
. What we see on the screen is the layer's geometry after it has been transformed by its transform
property. Note the function calls that look like layer.transform = // ..
. That's where we're setting the transform, which internally is just a struct
representing a 4 x 4 matrix of floating point values. Also note that the functions CATransform3DScale
and CATransform3DRotate
take the layer's current transform as a parameter. That's because we can compose several transforms together (which just means we multiply their matrices together), with the end result being as if you'd performed these transforms one by one. Note that we're only talking about the final result of the transform, not how Core Animation animates the layer!
Getting back to the perspective problem, what we need to know is that there's a value in our transform matrix that we can tweak to get the perspective effect that we're after. That value is a member of the transform structure, called m34 (the numbers indicate its position in the matrix). To get the effect we want, we need to set it to a small, negative number.
Uncomment the two commented sections in the ViewController.m file (the CATransform3D makePerspectiveTransform()
function and the lines leftPage.transform = makePerspectiveTransform(); rightPage.transform = makePerspectiveTransform();
and build again. This time round the 3D effect looks more believable.
Also note that when we change the transform property of a CALayer
, the deal comes with a "free" animation. This is what we want here - as opposed to the layer undergoing its transformation abruptly - but sometimes it's not.
Of course, perspective only goes as far, when our example becomes more sophisticated, we'll use shadows too! We might also want to round the corners of our "book" and masking our page layers with a CAShapeLayer
can help with that. Plus, we'd like to use a pinch gesture to control the folding/unfolding so that it feels more interactive. All this will be covered in the second part of this tutorial mini-series.
I encourage you to experiment with the code, referring to the API documentation, and try to implement our desired effect independently (you might even end up doing it better!).
Have fun with the tutorial, and thanks for reading!
Comments