"Flipbook" in the Gold Challenge?

To me, a “flipbook” is a simple manual creation that you do with pencil and paper – you draw each page with a tiny bit of movement so that when you flip the pages with your thumb, you’ve got an moving animation with no special equipment. I even went to Wikipedia (https://en.wikipedia.org/wiki/Flip_book) and looked it up to be sure there wasn’t some other meaning! So now I’m guessing the challenge really wants us to do is turn the photo collection into an app that displays one image per screen with some page-turning animation. But we can do that with a UIPageViewController – we don’t need collection views at all. The hints about subclassing the various UICollectionView classes and using transform make no sense to me. Am I missing the forest for the trees? Or is the trees for the forest?

Am I over-complicating this? Is all that’s being requested is 1) one photo image per screen, and 2) scroll from one photo image to the next?

Hi, Glenn!

I am also working on this challenge for a while - feel like my head is exploding. Did You make any progress? Anybody else? I really don’t know how to approach this - there seem to be too many options, but all of them quite hard. Currently trying to make use of the UICollectionViewLayout method layoutAttributesForInteractivelyMovingItemAtIndexPath:withTargetPosition: - essentially, because it appears to be quite possibly what could result in what I imagine; but using it is over my head. I will go through the Swift book next - right now I feel that this exceeds my capabilities; but still, would love to finish this in any way, so that I could put it aside before going back to Swift basics.

Cheers,
Björn

Hey guys, I know it’s been a while since you’ve posted but I’m wondering what progress you’ve made.

I’m also not quite sure what’s being asked. The way I interpret this “flip book” is a view with one of the images showing. If you swipe-up on the image (e.i. “flip”), then the current image is to be removed from the view using some sort of animation/transformation such that the image appears to “curl/scroll” out of view, revealing the next image (which could also be “flip” away to the next, and so on)

…I’ve got a feeling I’m wayyy overthinking this one…

I chose to think of this challenge more like a scrapbook. You have a page with images on it and rather than scrolling through them to view, you have set pages that you “flip” to see the next page of images. I didn’t use the subclassing approach at all for this challenge either. Sounds like maybe they wanted the image of the “page” to bend in half and then flip over. I used the .TransitionCurlUp and .TransitionCurlDown to create an animated effect of changing pages. Mine might be a tad crude, but it works and I think it looks pretty cool when animating.


import UIKit

struct Identifiers {
    static let CollectionPhotoCell = "Photo Cell"
    static let PhotoInfo = "Show Photo Info"
}


class PhotosViewController: UIViewController, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout, UIScrollViewDelegate {

    // MARK: - Outlets
    @IBOutlet var collectionView: UICollectionView! {
        didSet{
            collectionView.dataSource = photoDataSource
            collectionView.delegate = self
            
            let swipeUp = UISwipeGestureRecognizer(target: self, action: "pageUp")
            swipeUp.direction = .Up
            collectionView.addGestureRecognizer(swipeUp)
            
            let swipeDown = UISwipeGestureRecognizer(target: self, action: "pageDown")
            swipeDown.direction = .Down
            collectionView.addGestureRecognizer(swipeDown)
        }
    }
    
    // MARK: - Properties
    var store: PhotoStore!
    private let photoDataSource = PhotoDataSource()
    
    // MARK: - Delegate Methods
    func collectionView(collectionView: UICollectionView, willDisplayCell cell: UICollectionViewCell, forItemAtIndexPath indexPath: NSIndexPath) {
        let photo = photoDataSource.photos[indexPath.row]
        
        store.fetchImageForPhoto(photo) { (imageResult) -> Void in
            
            NSOperationQueue.mainQueue().addOperationWithBlock(){
                
                // Index path might have changed
                let photoIndex = self.photoDataSource.photos.indexOf(photo)!
                
                let photoIndexPath = NSIndexPath(forRow: photoIndex, inSection: 0)
                
                // when the request finishes, only update if still visible
                if let cell = self.collectionView.cellForItemAtIndexPath(photoIndexPath) as? PhotoCollectionViewCell {
                    cell.updateWithImage(photo.image)
                
                }
            }
        }
    }

    func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAtIndexPath indexPath: NSIndexPath) -> CGSize {
        
        if let flow = collectionViewLayout as? UICollectionViewFlowLayout {
            let width = itemWidth(flow)
            let height = itemHeight(flow)
            return CGSize(width: width, height: height)
        }
        return CGSize(width: 50.0, height: 50.0)
    }
    
    // MARK: - Sizing Helper Functions
    private func itemWidth(layout: UICollectionViewFlowLayout) -> CGFloat {
        return (collectionView.bounds.width - (layout.sectionInset.left * 2 + layout.minimumInteritemSpacing * 3)) / 4
    }
    
    private func itemHeight(layout: UICollectionViewFlowLayout) -> CGFloat {

        let minHeight = itemWidth(layout)
        let itemSpace = (collectionView.bounds.height - (layout.sectionInset.top + layout.sectionInset.bottom))
        let numRows = Int(itemSpace / (minHeight + layout.minimumInteritemSpacing))
        
        var adjustedHeight: CGFloat
        
        if numRows == 1 { // force minimum of 2 rows
            adjustedHeight = (itemSpace - layout.minimumInteritemSpacing * 2) / 2
        } else {
            adjustedHeight = (itemSpace - (layout.minimumInteritemSpacing * CGFloat(numRows))) / CGFloat(numRows)
        }
        return adjustedHeight
        
    }
    
    // MARK: - View Controller Lifecycle
    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view.
        store.fetchRecentPhotos() {
            (photosResult) -> Void in
            
            NSOperationQueue.mainQueue().addOperationWithBlock() {
                switch photosResult {
                case let .Success(photos):
                    print("Successfully found \(photos.count) recent photos")
                    self.photoDataSource.photos = photos
                case let .Failure(error):
                    self.photoDataSource.photos.removeAll()
                    print("Error fetching recent photos: \(error)")
                }
                self.collectionView.reloadSections(NSIndexSet(index: 0))
            }
        }
        automaticallyAdjustsScrollViewInsets = false
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }
    

    override func willAnimateRotationToInterfaceOrientation(toInterfaceOrientation: UIInterfaceOrientation, duration: NSTimeInterval) {
        collectionView.collectionViewLayout.invalidateLayout()
    }
    
    // MARK: - Paging Helpers
    func pageUp() {
        print("PageUP!")
        let indexPaths = collectionView.indexPathsForVisibleItems()
        
        var lastPath = NSIndexPath(forRow: 0, inSection: 0)
        for index in indexPaths {
            if index.row > lastPath.row { lastPath = index }
        }
        
        lastPath = NSIndexPath(forRow: lastPath.row + 1, inSection: lastPath.section)
        if lastPath.row < (photoDataSource.photos.count - 1) {
            collectionView.scrollToItemAtIndexPath(lastPath, atScrollPosition: .Top, animated: false) 
            UIView.transitionWithView(collectionView, duration: 0.5, options: [.TransitionCurlUp, .CurveEaseIn], animations: { self.view.layoutIfNeeded() }, completion: nil)

        }
    }
    
    func pageDown() {
        print("PageDown!")
        let indexPaths = collectionView.indexPathsForVisibleItems()
        
        var firstPath = NSIndexPath(forRow: photoDataSource.photos.count, inSection: 0)
        for index in indexPaths {
            if index.row < firstPath.row { firstPath = index }
        }
        
        if firstPath.row > 0 {
            firstPath = NSIndexPath(forRow: firstPath.row - 1, inSection: firstPath.section)

            collectionView.scrollToItemAtIndexPath(firstPath, atScrollPosition: .Bottom, animated: false)
            UIView.transitionWithView(collectionView, duration: 0.5, options: [.TransitionCurlDown, .CurveEaseIn], animations: {self.view.layoutIfNeeded()}, completion: nil)
            
        }
    }
    // MARK: - Navigation

    // In a storyboard-based application, you will often want to do a little preparation before navigation
    override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
        // Get the new view controller using segue.destinationViewController.
        // Pass the selected object to the new view controller.
        if segue.identifier == Identifiers.PhotoInfo {
            if let pivc = segue.destinationViewController as? PhotoInfoViewController {
                if let cell = sender as? PhotoCollectionViewCell {
                    
                    pivc.image = cell.imageView.image
                }
            }
        }
    }
}

I interpreted the “flip book” as:

  • Display one photo per page
  • Show a page flip animation when you scroll left or right to “flip” to the next or previous photo

I finally ended up getting this to work by adapting the BookLayout class described here:

The basic pieces of the solution are:

  • Create a custom UICollectionViewLayout class
  • Override collectionViewContentSize to set the width of the collection view equal to the width of your photos (I used the screen width) times the number of photos
  • Override shouldInvalidateLayoutForBoundsChange and return true so that the collection view updates the layout as you scroll
  • Override layoutAttributesForElementsInRect and call layoutAttributesForItemAtIndexPath for every item and return an array of all the items
  • In layoutAttributesForItemAtIndexPath set the layout attribute’s frame.origin.x property to be based on the collection view’s contentOffset.x property -> so essentially as you scroll through the collection view, you are constantly updating the position of all the photos to be in the same place visually on the screen (moving along with the scroll)
  • For the current photo (the page that should “flip” as you scroll) set the layout attributes’ transform3D property to be a 3D rotation of the photo based on how far you scroll (I had no idea how to do this - the link above was invaluable)

So in a nutshell, you are still setting the collection view to be a scrollable view as wide as all your photos aligned horizontally, but instead of scrolling through them in a line, on every scroll event your layout updates the position of all the unflipped photos to be stacked on top of each other in the middle of the screen and uses the scroll position to determine how “flipped” the top photo should be.

The end result is that as you scroll horizontally, it looks like you are flipping through the photos one by one.

The link above has all the basics of this approach - I would never have thought of using the layout methods in this way. (And everything I thought to try myself was unsuccessful.)

Hi alpha009,

could you please be so kind to paste your code and any single change you made? I have tried to follow all the steps but I always get the error message:

‘NSInternalInconsistencyException’, reason: ‘could not dequeue a view of kind: UICollectionElementKindCell with identifier UICollectionViewCell - must register a nib or a class for the identifier or connect a prototype cell in a storyboard’

I am using Xcode 7.3 for iOS 9.0

This is the first Challenge (or exercise) where I am really stuck. If you were able to solve this challenge, please be so kind to share your solution with us.

Thanking you in advance for your consideration I look forward to hearing from you soon.

Best regards,

Rolfino

Hi Rolfino,

Sure, here’s my code for my UICollectionViewLayout class. (Apologies that it’s a bit messy, once I got it working I never really went back to clean it up. In the getRatio function in particular, there are comments to myself that I’m pretty sure I implemented but didn’t remove some of the unnecessary code from the original example.)

class FlipbookLayout: UICollectionViewLayout {
    
    // based on the BookLayout class in
    // https://www.raywenderlich.com/94565/how-to-create-an-ios-book-open-animation-part-1
    
    private var photoWidth: CGFloat = 300
    private var photoHeight: CGFloat = 300
    private var numberOfPhotos = 0
    private var currentPhoto = 0
    
    override func prepareLayout() {
        
        print("ENTER \(#function)")
        
        super.prepareLayout()
        collectionView?.decelerationRate = UIScrollViewDecelerationRateFast
        numberOfPhotos = collectionView!.numberOfItemsInSection(0)
        print("Number of photos: \(numberOfPhotos)")
        collectionView?.pagingEnabled = true
    }
    
    override func shouldInvalidateLayoutForBoundsChange(newBounds: CGRect) -> Bool {
        return true
    }
    
    override func collectionViewContentSize() -> CGSize {
        return CGSizeMake(CGFloat(numberOfPhotos) * collectionView!.bounds.width, collectionView!.bounds.height - 150)
    }
    
    override func layoutAttributesForElementsInRect(rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        
        print("ENTER \(#function)")
        
        var array: [UICollectionViewLayoutAttributes] = []
        
        if numberOfPhotos == 0 {
            return array
        }
        
        for i in 0 ... max(0, numberOfPhotos - 1) {
            
            
            
            let indexPath = NSIndexPath(forItem: i, inSection: 0)
            
            
            if let attributes = layoutAttributesForItemAtIndexPath(indexPath) {
                array.append(attributes)
            } else {
                // print("Failed to create attributes")
            }
        }
        return array
    }
    
    override func layoutAttributesForItemAtIndexPath(indexPath: NSIndexPath) ->UICollectionViewLayoutAttributes? {
        
        let layoutAttributes = UICollectionViewLayoutAttributes(forCellWithIndexPath: indexPath)
        
        var frame = getFrame(collectionView!)
        if currentPhoto > indexPath.row {
            frame.origin.x -= photoWidth
        }
        layoutAttributes.frame = frame
        
        let ratio = getRatio(collectionView!, indexPath: indexPath)
        
        let rotation = getRotation(indexPath, ratio: ratio)
        layoutAttributes.transform3D = rotation
        
        // Not sure we need this
        if indexPath.row == 0 {
            layoutAttributes.zIndex = Int.max
        }
        
        layoutAttributes.zIndex = numberOfPhotos - indexPath.row
        
        return layoutAttributes
    }
    
    // MARK: - Attribute Logic Helpers
    
    func getFrame(collectionView: UICollectionView) -> CGRect {
        var frame = CGRect()
        
        // Modified
        
        
        frame.origin.x = collectionView.bounds.width - (photoWidth * 1.5) + collectionView.contentOffset.x
        
        
        frame.origin.y = (collectionViewContentSize().height - photoHeight) / 2
        
        photoWidth = collectionView.bounds.width
        photoHeight = photoWidth
        
        frame.size.width = photoWidth
        frame.size.height = photoHeight
        
        return frame
    }
    
    func getRatio(collectionView: UICollectionView, indexPath: NSIndexPath) -> CGFloat {
        // TO DO: we don't need to restrict our ratio between -0.5 and 0.5
        //        we can just have all the upcoming photos sitting flat (at 1.0)
        //        and all the flipped photos sitting flat off screen left (at -1.0)
        //        but I don't understand what this function does well enough to modify yet
        
        
        // Modified
        let page = CGFloat(indexPath.item)
        
        
        
        var ratio: CGFloat = -0.5 + page - (collectionView.contentOffset.x / collectionView.bounds.width)
        
        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)
        }
        
        
        //        print("--- in getRatio page \(page) ratio \(ratio) contentOffset.x \(collectionView.contentOffset.x)")
        
        currentPhoto = Int(collectionView.contentOffset.x / photoWidth)
        //        print("--- should see photo \(currentPhoto)")
        
        if indexPath.row == currentPhoto {
            ratio = (((CGFloat(currentPhoto) + 1) * photoWidth) - collectionView.contentOffset.x) / photoWidth
            print("+++ Ratio for currentPhoto \(ratio)")
        } else if indexPath.row > currentPhoto {
            ratio = 1.0
        } else {
            ratio = -1.0
        }
        
        return ratio
    }
    
    func getAngle(indexPath: NSIndexPath, ratio: CGFloat) -> CGFloat {
        // all modified heavily
        
        var angle: CGFloat = 0
        
        angle = (1 - ratio) * CGFloat(-M_PI_2)
        
        return angle
    }
    
    func makePerspectiveTransform() -> CATransform3D {
        var transform = CATransform3DIdentity
        transform.m34 = 1.0 / -2000
        return transform
    }
    
    func getRotation(indexPath: NSIndexPath, ratio: CGFloat) -> CATransform3D {
        var transform = makePerspectiveTransform()
        let angle = getAngle(indexPath, ratio: ratio)
        transform = CATransform3DRotate(transform, angle, 0, 1, 0)
        return transform
    }
}

If I remember correctly, I think I also had to update applyLayoutAttributes in my UICollectionViewCell class:

override func applyLayoutAttributes(layoutAttributes: UICollectionViewLayoutAttributes) {
    super.applyLayoutAttributes(layoutAttributes)
    layer.anchorPoint = CGPointMake(0, 0.5)
}

Hope that helps,
Jeff

Hi Jeff,

Thanks a lot for your help!

Actually your code is very similar to mine, except for a few lines in the function “getFrame(collectionView: UICollectionView) -> CGRect {…” where I tried to accomplish something very different using a completely different approach.

I’ll try to understand what makes my version crash, and then I will come back. It’s a pitty that the authors of this book gave us such a difficult challenge without neither touching on the basics of 3D transformation, rotation and many other topics.

Best regards,

Rolfino

Good luck, Rolfino.

I definitely found this to be the most difficult challenge in the book (and more difficult than any of the challenges in the Swift book, which I worked through before this one). I also found it strange that the authors didn’t include the information needed to solve the challenges.

Let me know if you get it working or figure out why your method was crashing.

Jeff

Thanks a lot Jeff,

I agree 100% with you!

I’ll keep you posted.

Rolfino

Hi alpha009, here I posted my solution: Gold Challenge (simple way)
The only problem is that if I click on odd numbered photos (those staying on the left hand side of the book’s spine) I always end up on the first photo, while if I click on even numbered photos (those staying on the right hand side of the book’s spine) I end up on the correct photo. If someone will be able to solve this issue, then he/she is really great!

/* — alpha009’s code simplified —*/

import UIKit

class Layout: UICollectionViewFlowLayout {

private var photoWidth: CGFloat = 300
private var photoHeight: CGFloat = 300
private var numberOfPhotos = 0
private var currentPhoto = 0

override func prepareLayout() {
    super.prepareLayout()
    collectionView?.decelerationRate = UIScrollViewDecelerationRateFast
    numberOfPhotos = collectionView!.numberOfItemsInSection(0)
    collectionView?.pagingEnabled = true
}

override func shouldInvalidateLayoutForBoundsChange(newBounds: CGRect) -> Bool {
    return true
}

override func collectionViewContentSize() -> CGSize {
    return CGSizeMake(CGFloat(numberOfPhotos) * collectionView!.bounds.width, collectionView!.bounds.height - (collectionView?.contentInset.top)!)
}

override func layoutAttributesForElementsInRect(rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
    
    var array: [UICollectionViewLayoutAttributes] = []
    
    if numberOfPhotos == 0 {
        return array
    }
    
    for i in 0 ... max(0, numberOfPhotos - 1) {
        let indexPath = NSIndexPath(forItem: i, inSection: 0)
        
        if let attributes = layoutAttributesForItemAtIndexPath(indexPath) {
            array.append(attributes)
        } else {
            // print("Failed to create attributes")
        }
    }
    return array
}

override func layoutAttributesForItemAtIndexPath(indexPath: NSIndexPath) ->UICollectionViewLayoutAttributes? {
    
    let layoutAttributes = UICollectionViewLayoutAttributes(forCellWithIndexPath: indexPath)
    
    let frame = getFrame(collectionView!)
    
    layoutAttributes.frame = frame
    
    let ratio = getRatio(collectionView!, indexPath: indexPath)
    
    let rotation = getRotation(indexPath, ratio: ratio)
    
    layoutAttributes.transform3D = rotation
    
    layoutAttributes.zIndex = numberOfPhotos - indexPath.item

    return layoutAttributes
}

// MARK: - Attribute Logic Helpers

func getFrame(collectionView: UICollectionView) -> CGRect {
    var frame = CGRect()
    
    photoWidth = collectionView.bounds.width
    photoHeight = photoWidth
    
    frame.origin.x = collectionView.bounds.width - (photoWidth * 1.5) + collectionView.contentOffset.x
    frame.origin.y = 0.0
    
    frame.size.width = photoWidth
    frame.size.height = photoHeight
    
    return frame
}

func getRatio(collectionView: UICollectionView, indexPath: NSIndexPath) -> CGFloat {
    
    var ratio: CGFloat = 1
    
    currentPhoto = Int(collectionView.contentOffset.x / photoWidth)
    
    if indexPath.row == currentPhoto {
        ratio = 1 +  CGFloat(currentPhoto) - (collectionView.contentOffset.x / collectionView.bounds.width)
    }
    else if indexPath.row > currentPhoto {
        ratio = 1.0
    }
    else {
        ratio = -1.0
    }
    return ratio
}

func getAngle(indexPath: NSIndexPath, ratio: CGFloat) -> CGFloat {
    var angle: CGFloat = 0
    
    angle = (1 - ratio) * CGFloat(-M_PI_2)
    
    return angle
}

func makePerspectiveTransform() -> CATransform3D {
    var transform = CATransform3DIdentity
    transform.m34 = 1.0 / -2000
    return transform
}

func getRotation(indexPath: NSIndexPath, ratio: CGFloat) -> CATransform3D {
    var transform = makePerspectiveTransform()
    let angle = getAngle(indexPath, ratio: ratio)
    transform = CATransform3DRotate(transform, angle, 0, 1, 0)
    return transform
}

}