Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

iOS Swift: How to recreate a Photos App UICollectionView Layout

I am wondering for quiet a while now how to create the iOS Photos App layout. How can I make it so it looks like zooming in to a collection while at the same time the navigation bar shows a back button?

Is it a new view controller which gets pushed onto a UINavigationController? And if so, how exactly do they manage to match the tiles while expanding.

Is there maybe even a 3rd party library which lets me easily recreate such a layout?

Hope you can help me to understand the concept of how this works.

like image 949
xxtesaxx Avatar asked Dec 14 '22 22:12

xxtesaxx


1 Answers

To answer your first question, "Is it a new view controller which gets pushed onto a UINavigationController?". Yes, it is a new view controller. What Apple is using here is a UIViewControllerTransitioningDelegate which allows you to present a custom animation on how a view controller is presented and dismissed.

Now on the second question, "Hope you can help me to understand the concept of how this works." There is no easy way to put it as quite a lot is involved. I have recreated the effect which I will show below but first I need to explain some core principles.

From Apple's docs,

When implementing your transitioning delegate object, you can return different animator objects depending on whether a view controller is being presented or dismissed. All transitions use a transition animator object—an object that conforms to the UIViewControllerAnimatedTransitioning protocol—to implement the basic animations. A transition animator object performs a set of animations over a finite period of time.

In other words, the UIViewControllerTransitioningDelegate expects an animator object which you create that describes how the view controller should be presented and how it should be dismissed. Only two of these delegates methods are of interest to what you want to achieve and these are:

    func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
    let animator = PresentAnimator()
    return animator
}

This asks your delegate for the transition animator object to use when presenting a view controller.

func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
    let animator = DismissAnimator()
    return animator
}

This asks your delegate for the transition animator object to use when dismissing a view controller.

Both the PresentAnimator and DismissAnimator object conform to UIViewControllerAnimatedTransitioning. From Apple's docs:

In your animator object, implement the transitionDuration(using:) method to specify the duration of your transition and implement the animateTransition(using:) method to create the animations themselves. Information about the objects involved in the transition is passed to your animateTransition(using:) method in the form of a context object. Use the information provided by that object to move the target view controller’s view on or off screen over the specified duration.

Basically, each animator object will describe the duration of the view controller's animation and how it will be animated.

Now here is a demonstration of all this. This is what we will achieve:

Final Product

Create two view controllers in your storyboard. My first view controller is called ViewController which contains a Collection View and a Collection View cell with an identifier "MediaCell" and an image that fills that collection view cell. The collection view cell has a class called ImageCollectionViewCell with only this:

    class ImageCollectionViewCell: UICollectionViewCell {

     @IBOutlet weak var image: UIImageView! //links to the collection view cell's image

     }

My second view controller is called ImageRevealViewController which simply has a single image view and a grey view at the top that I am using to simulate a navigation bar and a custom back button (I have tried all this with a normal UINavigationController nav bar but the dismiss animator fails to work. There is no shame through is making something that looks and acts like a navigation bar although mine is just for demo).

Storyboard

The Photo Album

This will be the code for your ViewController. Basically this will be the place the user finds a collection of photos just like the Photo Album. I used two test images for mine as you will see.

    import UIKit

    class ViewController: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {

@IBOutlet weak var collectionView: UICollectionView!

var selectedCell = UICollectionViewCell() //the selected cell, important for the animator

var media: [UIImage] = [UIImage]() //the photo album's images

override func viewDidLoad() {
    super.viewDidLoad()
    // Do any additional setup after loading the view, typically from a nib.

    media.append(UIImage(named: "testimage1")!)

    media.append(UIImage(named: "testimage2")!)

    collectionView.delegate = self

    collectionView.dataSource = self
}



func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {

    selectedCell = collectionView.cellForItem(at: indexPath)!

    let selectedCellImage = selectedCell as! ImageCollectionViewCell

    let mainStoryboard = UIStoryboard(name: "Main", bundle: nil)

    let imageRevealVC = mainStoryboard.instantiateViewController(withIdentifier: "ImageRevealVC") as! ImageRevealViewController

    imageRevealVC.transitioningDelegate = self

    imageRevealVC.imageToReveal = selectedCellImage.image.image

     /*
      This is where I tried using the nav controller but things did not work out for the dismiss animator. I have commented it out.
     */

    //let navController = UINavigationController(rootViewController: imageRevealVC)

    //navController.transitioningDelegate = self

    //navigationController?.pushViewController(imageRevealVC, animated: true)

    present(imageRevealVC, animated: true, completion: nil)

}

func numberOfSections(in collectionView: UICollectionView) -> Int {
    return 1
}

func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {

    return media.count

}

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {

    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "MediaCell", for: indexPath) as! ImageCollectionViewCell

    cell.image.image = media[indexPath.row]

    cell.image.contentMode = .scaleAspectFill

    return cell
}

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
    let itemsPerRow:CGFloat = 3
    let hardCodedPadding:CGFloat = 2
    let itemWidth = (collectionView.bounds.width / itemsPerRow) - hardCodedPadding
    let itemHeight = itemWidth
    return CGSize(width: itemWidth, height: itemHeight)
}




 }

    extension ViewController: UIViewControllerTransitioningDelegate {

func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
    let animator = PresentAnimator()
    animator.originFrame = selectedCell.frame //the selected cell gives us the frame origin for the reveal animation
    return animator
}

func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
    let animator = DismissAnimator()
    return animator
}




 }

The UIViewControllerTransitioningDelegate is at the end alongside the animator objects I talked about. Notice in the didSelect of the collection view that I instantiate the new view controller and make its transitioning delegate equal to self.

The Animators

There are always three steps to making an animator.

  1. Setup the transition
  2. Create the animations
  3. Complete the transitions

Now for the Present Animator. Create a new Swift class called PresentAnimator and add the following:

    import Foundation

     import UIKit

   class PresentAnimator: NSObject, UIViewControllerAnimatedTransitioning {

let duration = 0.5

var originFrame = CGRect.zero

func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
    return duration
}


func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {

    let containerView = transitionContext.containerView


    let toView = transitionContext.view(forKey: UITransitionContextViewKey.to)!


    //2) create animation
    let finalFrame = toView.frame

    let xScaleFactor = originFrame.width / finalFrame.width
    let yScaleFactor = originFrame.height / finalFrame.height

    let scaleTransform = CGAffineTransform(scaleX: xScaleFactor, y: yScaleFactor)

    toView.transform = scaleTransform
    toView.center = CGPoint(
        x: originFrame.midX,
        y: originFrame.midY
    )

    toView.clipsToBounds = true



    containerView.addSubview(toView)


    UIView.animate(withDuration: duration, delay: 0.0,
                   options: [], animations: {

                    toView.transform = CGAffineTransform.identity
                    toView.center = CGPoint(
                        x: finalFrame.midX,
                        y: finalFrame.midY
                    )

    }, completion: {_ in

        //3 complete the transition
        transitionContext.completeTransition(
            !transitionContext.transitionWasCancelled
        )
    })

}

   }

Now for the Dismiss Animator. Create a new class called DismissAnimator and add the following:

    import Foundation

    import UIKit

    class DismissAnimator: NSObject, UIViewControllerAnimatedTransitioning {

let duration = 0.5

func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
    return duration
}

func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {

    //1) setup the transition
    let containerView = transitionContext.containerView

    let fromView = transitionContext.view(forKey: UITransitionContextViewKey.from)!
    let toView = transitionContext.view(forKey: UITransitionContextViewKey.to)!

    containerView.insertSubview(toView, belowSubview: fromView)

    //2) animations!

    UIView.animate(withDuration: duration, delay: 0.0, options: [], animations: {

        fromView.transform = CGAffineTransform(scaleX: 0.1, y: 0.1)

    }, completion: {_ in

        //3) complete the transition
        transitionContext.completeTransition(
            !transitionContext.transitionWasCancelled
        )
    })


}

    }

The Image Revealed

Now for the final step, the view controller that reveals the image. In your ImageRevealController add this:

    import UIKit

    class ImageRevealViewController: UIViewController {

    var imageToReveal: UIImage!

@IBOutlet weak var imageRevealed: UIImageView!

override func viewDidLoad() {
    super.viewDidLoad()

    imageRevealed.image = imageToReveal



}


@IBAction func backButton(_ sender: Any) {

    dismiss(animated: true, completion: nil)

}

  }

The backButton connects to the button that I added to the view that acts like nav bar. You can add your own back indicator to make it more authentic.

For more info on UIViewControllerTransitioningDelegate there is a section here "From View Controller" disappears using UIViewControllerContextTransitioning, you could look into and to which I have contributed an answer.

like image 146
gwinyai Avatar answered May 07 '23 15:05

gwinyai