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 2 of 4 of this article. Click here to view the first page.

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:

  1. Create a new CGPoint called newOffset.
  2. Grab the current layout of the collection view.
  3. Get the total width of a cell.
  4. Calculate the current offset with respect to the center of the screen.
  5. If velocity.x > 0, the user is scrolling to the right. Think of offset/width as the book index you'd like to scroll to.
  6. If velocity.x = 0, the user didn't put enough oomph into scrolling, and the same book remains selected.
  7. If velocity.x < 0, the user is scrolling left.
  8. 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:

VN_PageFlipping

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:

VN_BookLayoutStoryboard

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:

  1. Set the deceleration rate to UIScrollViewDecelerationRateFast to increase the rate at which the scroll view slows down.
  2. Grab the number of pages in the current book.
  3. 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:

  1. Create a new array to hold UICollectionViewLayoutAttributes.
  2. Loop through all the items (pages) in the collection view.
  3. For each item in the collection view, create an NSIndexPath.
  4. Grab the attribute for the current indexPath. You'll override layoutAttributesForItemAtIndexPath(_:) soon.
  5. Add the attributes to your array.
  6. Return all the cell attributes.