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.
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:
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).
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.
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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With