How to Create an iOS Book Open Animation: Part 1
Learn how to create an iOS book open animation including page flips, with custom collection views layout and transitions. By Vincent Ngo.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
How to Create an iOS Book Open Animation: Part 1
30 mins
Handling the Page Geometry
Before you jump straight into the implementation of layoutAttributesForItemAtIndexPath(_:)
, take a minute to consider the layout, how it will work, and if you can write any helper methods to keep everything nice and modular. :]
The diagram above shows that every page flips with the book's spine as the axis of rotation. The ratios on the diagram range from -1.0 to 1.0. Why? Well, imagine a book laid out on a table, with the spine representing 0.0. When you turn a page from the left to the right, the "flipped" ratio goes from -1.0 (full left) to 1.0 (full right).
Therefore, you can represent your page flipping with the following ratios:
- 0.0 means a page is at a 90 degree angle, perpendicular to the table.
- +/- 0.5 means a page is at a 45 degree angle to the table.
- +/- 1.0 means a page is parallel to the table.
Note that since angle rotation is counterclockwise, the sign of the angle will be the opposite of the sign of the ratio.
First, add the following helper method after layoutAttributesForElementsInRect(_:)
:
//MARK: - Attribute Logic Helpers
func getFrame(collectionView: UICollectionView) -> CGRect {
var frame = CGRect()
frame.origin.x = (collectionView.bounds.width / 2) - (PageWidth / 2) + collectionView.contentOffset.x
frame.origin.y = (collectionViewContentSize().height - PageHeight) / 2
frame.size.width = PageWidth
frame.size.height = PageHeight
return frame
}
For every page, you calculate the frame with respect to the middle of the collection view. getFrame(_:)
will align every page's edge to the book's spine. The only variable that changes is the collection view's content offset in the x direction.
Next, add the following method after getFrame(_:)
:
func getRatio(collectionView: UICollectionView, indexPath: NSIndexPath) -> CGFloat {
//1
let page = CGFloat(indexPath.item - indexPath.item % 2) * 0.5
//2
var ratio: CGFloat = -0.5 + page - (collectionView.contentOffset.x / collectionView.bounds.width)
//3
if ratio > 0.5 {
ratio = 0.5 + 0.1 * (ratio - 0.5)
} else if ratio < -0.5 {
ratio = -0.5 + 0.1 * (ratio + 0.5)
}
return ratio
}
The method above calculates the page's ratio. Taking each commented section in turn:
- Calculate the page number of a page in the book — keeping in mind that pages in the book are double-sided. Multiplying by 0.5 gives you the exact page you're on.
- Calculate the
ratio
based on the weighted percentage of the page you're turning. - You need to restrict the page to a ratio between the range of -0.5 and 0.5. Multiplying by 0.1 creates a gap between each page to make it look like they overlap.
Once you've calculated the ratio, you'll use it to calculate the angle of the turning page.
Add the following code after getRatio(_:indexPath:)
:
func getAngle(indexPath: NSIndexPath, ratio: CGFloat) -> CGFloat {
// Set rotation
var angle: CGFloat = 0
//1
if indexPath.item % 2 == 0 {
// The book's spine is on the left of the page
angle = (1-ratio) * CGFloat(-M_PI_2)
} else {
//2
// The book's spine is on the right of the page
angle = (1 + ratio) * CGFloat(M_PI_2)
}
//3
// Make sure the odd and even page don't have the exact same angle
angle += CGFloat(indexPath.row % 2) / 1000
//4
return angle
}
There's a bit of math going on, but it's not so bad when you break it down:
- Check to see if the current page is even. This means that the page is to the right of the book's spine. A page turn to the right is counterclockwise, and pages on the right of the spine have a negative angle. Recall that the ratio you defined is between -0.5 and 0.5.
- If the current page is odd, the page is to the left of the book's spine. A page turn to the left is clockwise, and pages on the left side of the spine have a positive angle.
- Add a small angle to each page to give the pages some separation.
- Return the angle for rotation.
Once you have the angle, you need to transform each page. Add the following method:
func makePerspectiveTransform() -> CATransform3D {
var transform = CATransform3DIdentity
transform.m34 = 1.0 / -2000
return transform
}
Modifying the m34 of the transform matrix adds a bit of perspective to each page.
Now it's time to apply the rotation. Add the following code:
func getRotation(indexPath: NSIndexPath, ratio: CGFloat) -> CATransform3D {
var transform = makePerspectiveTransform()
var angle = getAngle(indexPath, ratio: ratio)
transform = CATransform3DRotate(transform, angle, 0, 1, 0)
return transform
}
Here you use the two previous helper methods to calculate the transform and the angle, and create a CATransform3D
to apply to the page along the y-axis.
Now that you have all the helper methods set up, you are finally ready to create the attributes for each cell. Add the following method after layoutAttributesForElementsInRect(_:)
:
override func layoutAttributesForItemAtIndexPath(indexPath: NSIndexPath) -> UICollectionViewLayoutAttributes! {
//1
var layoutAttributes = UICollectionViewLayoutAttributes(forCellWithIndexPath: indexPath)
//2
var frame = getFrame(collectionView!)
layoutAttributes.frame = frame
//3
var ratio = getRatio(collectionView!, indexPath: indexPath)
//4
if ratio > 0 && indexPath.item % 2 == 1
|| ratio < 0 && indexPath.item % 2 == 0 {
// Make sure the cover is always visible
if indexPath.row != 0 {
return nil
}
}
//5
var rotation = getRotation(indexPath, ratio: min(max(ratio, -1), 1))
layoutAttributes.transform3D = rotation
//6
if indexPath.row == 0 {
layoutAttributes.zIndex = Int.max
}
return layoutAttributes
}
You'll call this method for each cell in your collection view:
- Create a
UICollectionViewLayoutAttributes
object for the cell at the givenNSIndexPath
. - Set the frame of the attribute using the
getFrame
method you created to ensure it's always aligned with the book's spine. - Calculate the ratio of an item in the collection view using
getRatio
, which you wrote earlier. - Check that the current page is within the ratio's threshold. If not, don't display the cell. For optimization purposes (and because of common sense), you won't display the back-side of a page, but only those that are front-facing — except when it's the book's cover, which you display at all times.
- Apply a rotation and transform with the given ratio you calculated.
- Check if
indexPath
is the first page. If so, make sure itszIndex
is always on top of the other pages to avoid flickering effects.
Build and run your app, open up one of your books, flip through it and...whoa, what?
The pages seem to be anchored in their centers — not at the edge!
As the diagram shows, each page's anchor point is set at 0.5 for both x and y. Can you tell what you need to do to fix this?
It's clear you need to change the anchor point of a pages to its edge. If the page is on the right hand side of a book, the anchor point should be (0, 0.5). But if the page is on the left hand side of a book, the anchor point should be (1, 0.5).
Open BookPageCell.swift and add the following code:
override func applyLayoutAttributes(layoutAttributes: UICollectionViewLayoutAttributes!) {
super.applyLayoutAttributes(layoutAttributes)
//1
if layoutAttributes.indexPath.item % 2 == 0 {
//2
layer.anchorPoint = CGPointMake(0, 0.5)
isRightPage = true
} else { //3
//4
layer.anchorPoint = CGPointMake(1, 0.5)
isRightPage = false
}
//5
self.updateShadowLayer()
}
Here you override applyLayoutAttributes(_:)
, which applies the layout attributes created in BookLayout.
It's pretty straightforward code:
- Check to see if the current cell is even. This means that the book's spine is on the left of the page.
- Set the anchor point to the left side of the cell and set
isRightPage
totrue
. This variable helps you determine where the rounded corners of the pages should be. - If the current cell is odd, then the book's spine is on the right side of the page.
- Set the anchor point to the right side of the cell and set
isRightPage
tofalse
. - Finally, update the shadow layer of the current page.
Build and run your app; flip through the pages and things should look a little better:
That's it for the first part of this tutorial! Take some time to bask in the glory of what you've created — it's a pretty cool effect! :]