In this two-part tutorial series, you’ll develop a nifty iOS book open animation and page flip animation similar to Paper by FiftyThree:
- In Part 1 you’ll learn how to customize your collection view layouts and apply depth and shadow to make the app look realistic.
- In Part 2, you’ll learn to create custom transitions between different controllers in a sensible way and integrate gestures to create natural, intuitive transitions between views.
This tutorial is for intermediate to advanced developers; you’ll be working with custom transitions and custom collection view layouts.
If you’ve never worked with a collection view before, start with some of our other iOS tutorials first.
Note: Full credit goes to Attila Hegedüs for creating this awesome sample project.
Getting Started
Download the starter project for this tutorial here; extract the contents of the zip file, and open Paper.xcodeproj in Xcode.
Build and run the project in the simulator; you’ll see the following:
The app is pretty much fully built; you can scroll through your library of books and select one of your favorite books to view. But when was the last time you read a book which had its pages side-by-side? With a bit of UICollectionView know-how, you can dress up the page view quite a bit!
The Project Structure
Here’s a quick rundown of the most important bits of the starter project:
The Data Models folder contains three files:
- Books.plist contains sample book data. Each book contains an image cover along with an array of images to represent pages.
- BookStore.swift is a singleton that is only created once in the life cycle of the app. The BookStore’s job is to load data from Books.plist and create Book objects.
- Book.swift is a class that stores information related to the book, such as retrieving the book cover, the image for each page index, and the number of pages.
The Books folder contains two files:
- BooksViewController.swift is a subclass of
UICollectionViewController
. This class is responsible for displaying your list of books horizontally. - BookCoverCell.swift displays all your book covers; it’s used by BooksViewController.
In the Book folder you’ll find the following:
- BookViewController.swift is also a subclass of
UICollectionViewController
. Its purpose is to display the pages of the book when you select a book from BooksViewController. - BookPageCell.swift is used by BookViewController to display all the pages in a book.
Here’s what’s in the last folder, Helpers:
- UIImage+Helpers.swift is an extension for
UIImage
. The extension contains two utility methods, one to round the corners of an image, and another to scale an image down to a given size.
That’s all! Enough of the review — it’s time to lay down some code!
Customizing the Book Layout
First you need to to override the default layout for BooksViewController‘s collection view. The existing layout shows three big book covers that takes up the whole screen. You’ll scale it down a bit to make it look more pleasant, like so:
As you scroll, the cover image nearest the center of the screen grows in size to indicate it’s the active selection. As you keep scrolling, the book cover shrinks in size to indicate you’re setting it aside.
Create a group named Layout under the App\Books group. Next right-click the Layout folder and select New File…, then select the iOS\Source\Cocoa Touch Class template and click Next. Name the class BooksLayout, make it a subclass of UICollectionViewFlowLayout, and set Language to Swift.
Next you need to instruct BooksViewController‘s collection view to use your new layout.
Open Main.storyboard, click on BooksViewController then click on the Collection View. In the Attributes Inspector, set Layout to Custom and Class to BooksLayout as shown below:
Open BooksLayout.swift and add the following code above the BooksLayout class declaration.
private let PageWidth: CGFloat = 362 private let PageHeight: CGFloat = 568 |
These two constants will be used to set the size of the cell.
Now add the following initialization method within the class curly braces:
required init(coder aDecoder: NSCoder) { super.init(coder: aDecoder) scrollDirection = UICollectionViewScrollDirection.Horizontal //1 itemSize = CGSizeMake(PageWidth, PageHeight) //2 minimumInteritemSpacing = 10 //3 } |
Here’s what the code above does:
- Sets the collection view’s scroll view direction to horizontal.
- Sets the size of the cell to the page width of 362 and to a height of 568.
- Set the minimum spacing between cells to 10.
Next, add the following code after init(coder:)
:
override func prepareLayout() { super.prepareLayout() //The rate at which we scroll the collection view. //1 collectionView?.decelerationRate = UIScrollViewDecelerationRateFast //2 collectionView?.contentInset = UIEdgeInsets( top: 0, left: collectionView!.bounds.width / 2 - PageWidth / 2, bottom: 0, right: collectionView!.bounds.width / 2 - PageWidth / 2 ) } |
prepareLayout()
gives you the chance to perform any calculations before you come up with any layout information for each cell.
Taking each numbered comment in turn:
- Sets how fast the collection view will stop scrolling after a user lifts their finger. By setting it to
UIScrollViewDecelerationRateFast
the scroll view will decelerate much faster. Try playing around withNormal
vsFast
to see the difference! - Sets the content inset of the collection view so that the first book cover will always be centered.
Now you need to handle the layout information of each cell.
Add the following code below prepareLayout()
:
override func layoutAttributesForElementsInRect(rect: CGRect) -> [AnyObject]? { //1 var array = super.layoutAttributesForElementsInRect(rect) as! [UICollectionViewLayoutAttributes] //2 for attributes in array { //3 var frame = attributes.frame //4 var distance = abs(collectionView!.contentOffset.x + collectionView!.contentInset.left - frame.origin.x) //5 var scale = 0.7 * min(max(1 - distance / (collectionView!.bounds.width), 0.75), 1) //6 attributes.transform = CGAffineTransformMakeScale(scale, scale) } return array } |
layoutAttributesForElementsInRect(_:)
returns an array of UICollectionViewLayoutAttributes
objects, which provides the layout attributes for each cell. Here’s a breakdown of the code:
- Calling the superclass of
layoutAttributesForElementsInRect
returns an array that contains all default layout attributes for each cell. - Loop through each attribute in the array.
- Grab the frame for the current cell attribute.
- Calculate the distance between the book cover — that is, the cell — and the center of the screen.
- Scale the book cover between a factor of 0.75 and 1 depending on the distance calculated above. You then scale all book covers by 0.7 to keep them nice and small.
- Finally, apply the scale to the book cover.
Next, add the following code right after layoutAttributesForElementsInRect(_:)
:
override func shouldInvalidateLayoutForBoundsChange(newBounds: CGRect) -> Bool { return true } |
Returning true
forces the layout to recalculate its attributes every time the collection view’s bound changes. A UICollectionView
changes its bounds while scrolling, which is perfect for recalculating the cell’s attribute.
Build and run your app; you’ll see that book in the middle of the view is larger than the others:
Scroll through the books to see how each book cover scales up and down. But wouldn’t it be great if the book could snap into place, indicating the selection?
The next method you’ll add will do just that!
Snapping to a Book
targetContentOffsetForProposedContentOffset(_:withScrollingVelocity:)
determines at which point the collection view should stop scrolling, and returns a proposed offset to set the collection view’s contentOffset
. If you don’t override this method, it just returns the default offset.
Add the following code after shouldInvalidateLayoutForBoundsChange(_:)
:
override func targetContentOffsetForProposedContentOffset(proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint { // Snap cells to centre //1 var newOffset = CGPoint() //2 var layout = collectionView!.collectionViewLayout as! UICollectionViewFlowLayout //3 var width = layout.itemSize.width + layout.minimumLineSpacing //4 var offset = proposedContentOffset.x + collectionView!.contentInset.left //5 if velocity.x > 0 { //ceil returns next biggest number offset = width * ceil(offset / width) } else if velocity.x == 0 { //6 //rounds the argument offset = width * round(offset / width) } else if velocity.x < 0 { //7 //removes decimal part of argument offset = width * floor(offset / width) } //8 newOffset.x = offset - collectionView!.contentInset.left newOffset.y = proposedContentOffset.y //y will always be the same... return newOffset } |
Here’s how you calculate the proposed offset for your book covers once the user lifts their finger:
- Create a new
CGPoint
callednewOffset
. - Grab the current layout of the collection view.
- Get the total width of a cell.
- Calculate the current offset with respect to the center of the screen.
- If
velocity.x > 0
, the user is scrolling to the right. Think ofoffset/width
as the book index you’d like to scroll to. - If
velocity.x = 0
, the user didn’t put enough oomph into scrolling, and the same book remains selected. - If
velocity.x < 0
, the user is scrolling left. - Update the new x offset and return. This guarantees that a book will always be centered in the middle.
Build and run your app; scroll through them again and you should notice that the scrolling action is a lot snappier:
To finish up this layout, you need to create a mechanism to restrict the user to click only the book in the middle. As of right now, you can currently click any book regardless of its position.
Open BooksViewController.swift and place the following code under the comment // MARK: Helpers
:
func selectedCell() -> BookCoverCell? { if let indexPath = collectionView?.indexPathForItemAtPoint(CGPointMake(collectionView!.contentOffset.x + collectionView!.bounds.width / 2, collectionView!.bounds.height / 2)) { if let cell = collectionView?.cellForItemAtIndexPath(indexPath) as? BookCoverCell { return cell } } return nil } |
selectedCell()
will always return the middle cell.
Next, replace openBook(_:)
with the following:
func openBook() { let vc = storyboard?.instantiateViewControllerWithIdentifier("BookViewController") as! BookViewController vc.book = selectedCell()?.book // UICollectionView loads it's cells on a background thread, so make sure it's loaded before passing it to the animation handler dispatch_async(dispatch_get_main_queue(), { () -> Void in self.navigationController?.pushViewController(vc, animated: true) return }) } |
This simply uses the new selectedCell
method you wrote rather than taking a book
as a parameter.
Next, replace collectionView(_:didSelectItemAtIndexPath:)
with the following:
override func collectionView(collectionView: UICollectionView, didSelectItemAtIndexPath indexPath: NSIndexPath) { openBook() } |
This simply removes the code that opened the book at the selected index; now you'll always open the book in the center of the screen.
Build and run your app; you'll notice now that the book in the center of the view is always the one that opens.
You're done with BooksLayout. It's time to make the on-screen book more realistic, and let the user flip the pages in the book!
Book Flipping Layout
Here's the final effect you're shooting for:
Now that looks more like a book! :]
Create a group named Layout under the Book group. Next, right-click the Layout folder and select New File..., then select the iOS\Source\Cocoa Touch Class template and click Next. Name the class BookLayout, make it a subclass of UICollectionViewFlowLayout, and set Language to Swift.
Just as before, your book collection view needs to use the new layout. Open Main.storyboard and select the Book View Controller Scene. Select the collection view and set the Layout to Custom. Finally, set the layout Class to BookLayout as shown below:
Open BookLayout.swift and add the following code above the BookLayout
class declaration:
private let PageWidth: CGFloat = 362 private let PageHeight: CGFloat = 568 private var numberOfItems = 0 |
You'll use these constant variables to set the size of every cell; as well, you're keeping track of the total number of pages in the book.
Next, add the following code inside the class declaration:
override func prepareLayout() { super.prepareLayout() collectionView?.decelerationRate = UIScrollViewDecelerationRateFast numberOfItems = collectionView!.numberOfItemsInSection(0) collectionView?.pagingEnabled = true } |
This is similar to what you did in BooksLayout
, with the following differences:
- Set the deceleration rate to
UIScrollViewDecelerationRateFast
to increase the rate at which the scroll view slows down. - Grab the number of pages in the current book.
- Enable paging; this lets the view scroll at fixed multiples of the collection view's frame width (rather than the default of continuous scrolling).
Still in BookLayout.swift, add the following code:
override func shouldInvalidateLayoutForBoundsChange(newBounds: CGRect) -> Bool { return true } |
Again, returning true
lets the layout update every time the user scrolls.
Next, give the collection view a content size by overriding collectionViewContentSize()
as shown below:
override func collectionViewContentSize() -> CGSize { return CGSizeMake((CGFloat(numberOfItems / 2)) * collectionView!.bounds.width, collectionView!.bounds.height) } |
This returns the overall size of the content area. The height of the content will always stay the same, but the overall width of the content is the number of items — that is, pages — divided by two multiplied by the screen's width. The reason you divide by two is that book pages are double sided; there's content on both sides of the page.
Just as you did in BooksLayout
, you need to override layoutAttributesForElementsInRect(_:)
so you can add the paging effect to your cells.
Add the following code just after collectionViewContentSize()
:
override func layoutAttributesForElementsInRect(rect: CGRect) -> [AnyObject]? { //1 var array: [UICollectionViewLayoutAttributes] = [] //2 for i in 0 ... max(0, numberOfItems - 1) { //3 var indexPath = NSIndexPath(forItem: i, inSection: 0) //4 var attributes = layoutAttributesForItemAtIndexPath(indexPath) if attributes != nil { //5 array += [attributes] } } //6 return array } |
Rather than calculating the attributes within this method like you did in BooksLayout
, you leave this task up to layoutAttributesForItemAtIndexPath(_:)
, as all cells are within the visible rect at any given time in the book implementation.
Here's a line by line explanation:
- Create a new array to hold
UICollectionViewLayoutAttributes
. - Loop through all the items (pages) in the collection view.
- For each item in the collection view, create an
NSIndexPath
. - Grab the attribute for the current
indexPath
. You'll overridelayoutAttributesForItemAtIndexPath(_:)
soon. - Add the attributes to your array.
- Return all the cell attributes.
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 layoutAttributesForItemAtIndexPath(_:)
:
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! :]
Where to Go From Here?
You can download the completed project from Part 1 that contains all the source code.
You started out with the default layouts for a collection view, and learned to customize a new layout to turn it into something truly amazing! Someone using this app will feel like they are flipping through a real book. It's the little things that turn a normal reader app into something that people can feel truly connected with.
However, you're not done yet! You'll make this app even better and more intuitive in Part 2 of this tutorial, where you'll explore custom transitions between the closed and opened book views.
Do you have any crazy layout ideas you are considering for your own app? If you have any questions, comments or other ideas from this tutorial, please join the discussion below!
The post How to Create an iOS Book Open Animation: Part 1 appeared first on Ray Wenderlich.