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.

Leave a rating/review
Save for later
Share
You are currently viewing page 3 of 4 of this article. Click here to view the first page.

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. :]

VN_PaperRatioDiagram

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:

  1. 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.
  2. Calculate the ratio based on the weighted percentage of the page you're turning.
  3. 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:

  1. 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.
  2. 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.
  3. Add a small angle to each page to give the pages some separation.
  4. 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:

  1. Create a UICollectionViewLayoutAttributes object for the cell at the given NSIndexPath.
  2. Set the frame of the attribute using the getFrame method you created to ensure it's always aligned with the book's spine.
  3. Calculate the ratio of an item in the collection view using getRatio, which you wrote earlier.
  4. 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.
  5. Apply a rotation and transform with the given ratio you calculated.
  6. Check if indexPath is the first page. If so, make sure its zIndex 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?
misc-jackie-chan

The pages seem to be anchored in their centers — not at the edge!

VN_Anchor1

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?

VN_CorrectRatio

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:

  1. Check to see if the current cell is even. This means that the book's spine is on the left of the page.
  2. Set the anchor point to the left side of the cell and set isRightPage to true. This variable helps you determine where the rounded corners of the pages should be.
  3. If the current cell is odd, then the book's spine is on the right side of the page.
  4. Set the anchor point to the right side of the cell and set isRightPage to false.
  5. 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:

VN_CompletePart1

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! :]