Create transition and interaction like iOS Photos app

Masamichi Ueta
9 min readMay 14, 2018

--

Amazing illustration by @pablostanley!

iOS Photos app transition and interaction

The user experience of iOS Photos app transition and interaction is nice.

The photo zoom in by tap the cell, and zoom out by pull down.

I created a clone of this transition and interaction.

What you can do with this article

The sample code is in the GitHub repository.

masamichiueta/FluidPhoto

Implementation

Let's implement it by three steps.

  1. Build the structure of the ViewControllers
  2. Implement zoom animation
  3. Implement interactive transition by pan gesture

1. Build the structure of the ViewControllers

The structure of the ViewControllers is like this.

There are 4 ViewControllers.

  1. CollectionViewController: List photos
  2. PageViewController: Paging photos
  3. ZoomImageViewController: Zoom in and out the image in 2
  4. ContainerViewController: Wrap 2

The ContainerViewController is not required, but when you want to place a toolbar on PageViewController, you cannot put it on the Storyboard, so use ContainerViewController.

Tap on the CollectionViewCell makes a screen transition and after the transition, PageViewController paginate the photos.

PageViewController instantiate the ZoomImageViewController at UIPageViewControllerDelegate and display it.

This is the real storyboard in this project.

2. Implement the zoom animation

UIViewControllerTransitioningDelegat and UIViewControllerAnimatedTransitioning are used to create custom animations.

I created two classes.

  1. ZoomTransitionController・・・implements UIViewControllerTransitioningDelegate or UINavigationControllerDelegate and manage transition.
  2. ZoomAnimator・・・implements UIViewControllerAnimatedTransitioning and zoom animation logic.

ZoomAnimator is a property of ZoomTransitionController.

The reason why I split ZoomAnimator and ZoomTransitionController in two classes is to manage interactive transition and normal transition.

ZoomAnimator is not interactive transition.

ZoomAnimator

ZoomAnimator is responsible for the logic of zoom animation. In other words, ZoomAnimator acquires the UIImageView to be zoomed and animate from the transition source frame to the transition destination frame.

Use the delegate to get the transition image and its frame. This delegate is implemented by transition source / transition destination ViewController.

protocol ZoomAnimatorDelegate: class {
func transitionWillStartWith(zoomAnimator: ZoomAnimator)
func transitionDidEndWith(zoomAnimator: ZoomAnimator)
func referenceImageView(for zoomAnimator: ZoomAnimator) -> UIImageView?
func referenceImageViewFrameInTransitioningView(for zoomAnimator: ZoomAnimator) -> CGRect?
}

ZoomAnimator has these properties.

  • fromDelegate...Source
  • toDelegate...Destination
  • isPresenting...Determine whether you are zooming in from the list to show details or zoom out from the details.
  • transitionImageView...Animated image
class ZoomAnimator: NSObject {

weak var fromDelegate: ZoomAnimatorDelegate?
weak var toDelegate: ZoomAnimatorDelegate?

var transitionImageView: UIImageView?
var isPresenting: Bool = true

From here, I explain the zoom animation logic.

First, this is the two methods of UIViewControllerAnimatedTransitioning.

extension ZoomAnimator: UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
if self.isPresenting {
return 0.5
} else {
return 0.25
}
}

func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
if self.isPresenting {
animateZoomInTransition(using: transitionContext)
} else {
animateZoomOutTransition(using: transitionContext)
}
}
}

transitionDuration is the duration of animation.

animateTransition is where animation actually execute. In this method, isPresenting property is used to check zoom-in or zoom-out.

Animation Logic

Next let's check the actual animation part.

The logic is constructed 4 parts.

  1. Hide the source and destination image
  2. Create an image to animate from the source image
  3. Calculate the frame of the destination image
  4. Animate image from source frame to destination frame

Zoom-in

fileprivate func animateZoomInTransition(using transitionContext: UIViewControllerContextTransitioning) {

let containerView = transitionContext.containerView

guard let toVC = transitionContext.viewController(forKey: .to),
let fromVC = transitionContext.viewController(forKey: .from),

// Get the source imageView
let fromReferenceImageView = self.fromDelegate?.referenceImageView(for: self),

// Get the destination imageView
let toReferenceImageView = self.toDelegate?.referenceImageView(for: self),

// Get the frame of source imageView
let fromReferenceImageViewFrame = self.fromDelegate?.referenceImageViewFrameInTransitioningView(for: self)
else {
return
}

self.fromDelegate?.transitionWillStartWith(zoomAnimator: self)
self.toDelegate?.transitionWillStartWith(zoomAnimator: self)

toVC.view.alpha = 0

// Hide the destination imageView
toReferenceImageView.isHidden = true
containerView.addSubview(toVC.view)

let referenceImage = fromReferenceImageView.image!

// Generate imageView to use for zoom animation
if self.transitionImageView == nil {
let transitionImageView = UIImageView(image: referenceImage)
transitionImageView.contentMode = .scaleAspectFill
transitionImageView.clipsToBounds = true
transitionImageView.frame = fromReferenceImageViewFrame
self.transitionImageView = transitionImageView
containerView.addSubview(transitionImageView)
}

// Hide the source imageView
fromReferenceImageView.isHidden = true

// Calculate destination imageView frame after animation
let finalTransitionSize = calculateZoomInImageFrame(image: referenceImage, forView: toVC.view)

UIView.animate(withDuration: transitionDuration(using: transitionContext),
delay: 0,
usingSpringWithDamping: 0.8,
initialSpringVelocity: 0,
options: [UIViewAnimationOptions.transitionCrossDissolve],
animations: {

// Update the frame of imageView
self.transitionImageView?.frame = finalTransitionSize
toVC.view.alpha = 1.0
fromVC.tabBarController?.tabBar.alpha = 0
},
completion: { completed in

// Remove the imageView
self.transitionImageView?.removeFromSuperview()

// Show source and destination imageView
toReferenceImageView.isHidden = false
fromReferenceImageView.isHidden = false

self.transitionImageView = nil

transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
self.toDelegate?.transitionDidEndWith(zoomAnimator: self)
self.fromDelegate?.transitionDidEndWith(zoomAnimator: self)
})
}

Zoom-out

fileprivate func animateZoomOutTransition(using transitionContext: UIViewControllerContextTransitioning) {
let containerView = transitionContext.containerView

guard let toVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to),
let fromVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from),
let fromReferenceImageView = self.fromDelegate?.referenceImageView(for: self),
let toReferenceImageView = self.toDelegate?.referenceImageView(for: self),
let fromReferenceImageViewFrame = self.fromDelegate?.referenceImageViewFrameInTransitioningView(for: self),
let toReferenceImageViewFrame = self.toDelegate?.referenceImageViewFrameInTransitioningView(for: self)
else {
return
}

self.fromDelegate?.transitionWillStartWith(zoomAnimator: self)
self.toDelegate?.transitionWillStartWith(zoomAnimator: self)

toReferenceImageView.isHidden = true

let referenceImage = fromReferenceImageView.image!

if self.transitionImageView == nil {
let transitionImageView = UIImageView(image: referenceImage)
transitionImageView.contentMode = .scaleAspectFill
transitionImageView.clipsToBounds = true
transitionImageView.frame = fromReferenceImageViewFrame
self.transitionImageView = transitionImageView
containerView.addSubview(transitionImageView)
}

containerView.insertSubview(toVC.view, belowSubview: fromVC.view)
fromReferenceImageView.isHidden = true

let finalTransitionSize = toReferenceImageViewFrame

UIView.animate(withDuration: transitionDuration(using: transitionContext),
delay: 0,
options: [],
animations: {
fromVC.view.alpha = 0
self.transitionImageView?.frame = finalTransitionSize
toVC.tabBarController?.tabBar.alpha = 1
}, completion: { completed in

self.transitionImageView?.removeFromSuperview()
toReferenceImageView.isHidden = false
fromReferenceImageView.isHidden = false

transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
self.toDelegate?.transitionDidEndWith(zoomAnimator: self)
self.fromDelegate?.transitionDidEndWith(zoomAnimator: self)

})
}

ZoomTransitionController

ZoomTransitionController has the same property as ZoomAnimator to refer to the source and destination as a delegate.

class ZoomTransitionController: NSObject {

let animator: ZoomAnimator

weak var fromDelegate: ZoomAnimatorDelegate?
weak var toDelegate: ZoomAnimatorDelegate?

...

ZoomTransitionController returns ZoomAnimator at the beginning of the transition.

extension ZoomTransitionController: UIViewControllerTransitioningDelegate {
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
self.animator.isPresenting = true
self.animator.fromDelegate = fromDelegate
self.animator.toDelegate = toDelegate
return self.animator
}

func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
self.animator.isPresenting = false
let tmp = self.fromDelegate
self.animator.fromDelegate = self.toDelegate
self.animator.toDelegate = tmp
return self.animator
}
}

extension ZoomTransitionController: UINavigationControllerDelegate {
func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationControllerOperation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {

if operation == .push {
self.animator.isPresenting = true
self.animator.fromDelegate = fromDelegate
self.animator.toDelegate = toDelegate
} else {
self.animator.isPresenting = false
let tmp = self.fromDelegate
self.animator.fromDelegate = self.toDelegate
self.animator.toDelegate = tmp
}

return self.animator
}
}

The zoom animation setting is now completed.
Let's use ZoomTransitionController on the ViewController to make zoom transitions.

ViewController

ContainerViewController has ZoomTransitionController as property.

class PhotoPageContainerViewController: UIViewController, UIGestureRecognizerDelegate {
var transitionController = ZoomTransitionController()
...
}

In CollectionView, when the cell is tapped, it executes Segue and start transition. Set animation delegates in prepare method.

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if segue.identifier == "ShowPhotoPageView" {
let nav = self.navigationController
let vc = segue.destination as! PhotoPageContainerViewController

// Set navigationController delegate to ZoomTransitionController
nav?.delegate = vc.transitionController
vc.transitionController.fromDelegate = self
vc.transitionController.toDelegate = vc

...
}
}

Now the transition by ZoomTransitionController is executed when UINavigationController transition occurs.

The rest is completed by implementing ZoomAnimatorDelegate on each ViewController and telling ZoomAnimator the image and frame of the source and destination.

CollectionViewController

// CollectionView側
extension CollectionViewController: ZoomAnimatorDelegate {
func transitionWillStartWith(zoomAnimator: ZoomAnimator) {

}

func transitionDidEndWith(zoomAnimator: ZoomAnimator) {

// CollectionViewのCellの位置を調整する
let cell = self.collectionView.cellForItem(at: self.selectedIndexPath) as! PhotoCollectionViewCell

let cellFrame = self.collectionView.convert(cell.frame, to: self.view)

if cellFrame.minY < self.collectionView.contentInset.top {
self.collectionView.scrollToItem(at: self.selectedIndexPath, at: .top, animated: false)
} else if cellFrame.maxY > self.view.frame.height - self.collectionView.contentInset.bottom {
self.collectionView.scrollToItem(at: self.selectedIndexPath, at: .bottom, animated: false)
}
}

func referenceImageView(for zoomAnimator: ZoomAnimator) -> UIImageView? {
// CollectionViewの画像を返す
let cell = self.collectionView.cellForItem(at: self.selectedIndexPath) as! PhotoCollectionViewCell
return cell.imageView
}

func referenceImageViewFrameInTransitioningView(for zoomAnimator: ZoomAnimator) -> CGRect? {
// CollectionViewの画像のフレームを返す

let cell = self.collectionView.cellForItem(at: self.selectedIndexPath) as! PhotoCollectionViewCell

let cellFrame = self.collectionView.convert(cell.frame, to: self.view)

if cellFrame.minY < self.collectionView.contentInset.top {
return CGRect(x: cellFrame.minX, y: self.collectionView.contentInset.top, width: cellFrame.width, height: cellFrame.height - (self.collectionView.contentInset.top - cellFrame.minY))
}

return cellFrame
}
}

ContainerViewController

extension ContainerViewController: ZoomAnimatorDelegate {
func transitionWillStartWith(zoomAnimator: ZoomAnimator) {
}

func transitionDidEndWith(zoomAnimator: ZoomAnimator) {
}

func referenceImageView(for zoomAnimator: ZoomAnimator) -> UIImageView? {
return self.currentViewController.imageView
}

func referenceImageViewFrameInTransitioningView(for zoomAnimator: ZoomAnimator) -> CGRect? {
return self.currentViewController.scrollView.convert(self.currentViewController.imageView.frame, to: self.currentViewController.view)
}
}

OK, zoom-in and zoom-out animation is completed.

3. Implement interactive transition with Pan gesture

To back to the collection view by pulling down like iOS Photo app, it is necessary to implement an interactive transition by pan gesture.

In this project, I add UIPanGestureRecognizer to ContainerViewController, and detect the gesture of pulling downward, and perform interactive screen transition.

Interactive transition of iOS Photo app

The characteristics of iOS Photo app transition

  • If you pull the photo downward while not zooming, the photo gradually becomes smaller. Minimum size exists
  • The photo comes with the position of the finger when you pull the photo downwards
  • The background becomes transparent according to the amount of pulling the photo
  • When you take your finger upwards, the photo returns to its original position

Implementation strategy

  1. I introduce a new class ZoomDismissalInteractionController that responsible for animation of interactive transitions.
  2. We add a property of ZoomDismissalInteractionController and delegate method to ZoomTransitionController to perform interactive transitions
  3. It is PageViewController that performs interactive transitions using pan gestures. Therefore, UIPanGestureRecognizer is added to PageViewController.

ZoomDismissalInteractionController

ZoomDismissalInteractionController implements UIViewControllerInteractiveTransitioning and is responsible for the logic of interactive transitions.

ZoomDismissalInteractionController has a method called each time a user did a pan gesture and controls the animation of the photo according to the state of the current pan gesture.

class ZoomDismissalInteractionController: NSObject {
...

// Called each time a user did a pan gesture
func didPanWith(gestureRecognizer: UIPanGestureRecognizer) {

// The center point of source image
let anchorPoint = CGPoint(x: fromReferenceImageViewFrame.midX, y: fromReferenceImageViewFrame.midY)

// The translation of the image
let translatedPoint = gestureRecognizer.translation(in: fromReferenceImageView)

// The vertical translation of the image
let verticalDelta = translatedPoint.y < 0 ? 0 : translatedPoint.y

let backgroundAlpha = backgroundAlphaFor(view: fromVC.view, withPanningVerticalDelta: verticalDelta)

let scale = scaleFor(view: fromVC.view, withPanningVerticalDelta: verticalDelta)

// Update image size according to translation of the pan gesture
transitionImageView.transform = CGAffineTransform(scaleX: scale, y: scale)

// Calculate the position of image to use animation
let newCenter = CGPoint(x: anchorPoint.x + translatedPoint.x, y: anchorPoint.y + translatedPoint.y - transitionImageView.frame.height * (1 - scale) / 2.0)
transitionImageView.center = newCenter

// Update interactive transition by scale
transitionContext.updateInteractiveTransition(1 - scale)


if gestureRecognizer.state == .ended {

let velocity = gestureRecognizer.velocity(in: fromVC.view)

// If the user take the image upwards, cancel the transition
if velocity.y < 0 || newCenter.y < anchorPoint.y {

// Cancel
}

// Animate
}
}

}

extension ZoomDismissalInteractionController: UIViewControllerInteractiveTransitioning {
func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning) {
self.transitionContext = transitionContext

let containerView = transitionContext.containerView

guard let animator = self.animator as? ZoomAnimator,
let fromVC = transitionContext.viewController(forKey: .from),
let toVC = transitionContext.viewController(forKey: .to),
let fromReferenceImageViewFrame = animator.fromDelegate?.referenceImageViewFrameInTransitioningView(for: animator),
let toReferenceImageViewFrame = animator.toDelegate?.referenceImageViewFrameInTransitioningView(for: animator),
let fromReferenceImageView = animator.fromDelegate?.referenceImageView(for: animator)
else {
return
}

animator.fromDelegate?.transitionWillStartWith(zoomAnimator: animator)
animator.toDelegate?.transitionWillStartWith(zoomAnimator: animator)

self.fromReferenceImageViewFrame = fromReferenceImageViewFrame
self.toReferenceImageViewFrame = toReferenceImageViewFrame

let referenceImage = fromReferenceImageView.image!

containerView.insertSubview(toVC.view, belowSubview: fromVC.view)
if animator.transitionImageView == nil {
let transitionImageView = UIImageView(image: referenceImage)
transitionImageView.contentMode = .scaleAspectFill
transitionImageView.clipsToBounds = true
transitionImageView.frame = fromReferenceImageViewFrame
animator.transitionImageView = transitionImageView
containerView.addSubview(transitionImageView)
}
}

Add ZoomDismissalInteractionController and delegate methods to ZoomTransitionController

Add ZoomDismissalInteractionController to the property and use this transition logic for interactive transitions.

class ZoomTransitionController: NSObject {

// Add
let interactionController: ZoomDismissalInteractionController

//
var isInteractive: Bool = false

// Add, wrapper to call didPanWith in ZoomDismissalInteractionController
func didPanWith(gestureRecognizer: UIPanGestureRecognizer) {
self.interactionController.didPanWith(gestureRecognizer: gestureRecognizer)
}
}

Also, in order to implement interactive transitions, you need to implement additional methods of UIViewControllerTransitioningDelegate or UINavigationControllerDelegate.

UIViewControllerTransitioningDelegate

  • func interactionControllerForPresentation(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning?
  • func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning?

UINavigationControllerDelegate

  • func navigationController(_ navigationController: UINavigationController, interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning?
extension ZoomTransitionController: UIViewControllerTransitioningDelegate {
...

// Add
func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
if !self.isInteractive {
return nil
}

self.interactionController.animator = animator
return self.interactionController
}

}

That is the basis for interactive transitions. After that we just use this gesture to actually perform this screen transition.

Actually perform interactive transitions with UIPanGesgureRecognizer

To be able to pull the zoomed image downwards, you need to add a UIPanGestureRecognizer to the PageViewController to recognize pan gestures.

Give UIPanGestureRecognizer to PageViewController from ContainerViewController.

class ContainerViewController: UIViewController, UIGestureRecognizerDelegate {
override func viewDidLoad() {
...

self.panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(didPanWith(gestureRecognizer:)))
self.panGestureRecognizer.delegate = self
self.pageViewController.view.addGestureRecognizer(self.panGestureRecognizer)

...

}
}

Now PageViewController can recognize pan gestures.

The flow of the processing part of pan gesture is like follows.

  1. At the start of processing, set interactive transition
  2. When pan gesture is executing, call the didPanWith method of ZoomTransitionController to process interactive transition
  3. When the gesture is over, make settings to exit the transition with ZoomTransitionController
@objc func didPanWith(gestureRecognizer: UIPanGestureRecognizer) {
switch gestureRecognizer.state {
case .began:
// 1
self.currentViewController.scrollView.isScrollEnabled = false
self.transitionController.isInteractive = true
let _ = self.navigationController?.popViewController(animated: true)
case .ended:
// 3.
if self.transitionController.isInteractive {
self.currentViewController.scrollView.isScrollEnabled = true
self.transitionController.isInteractive = false
self.transitionController.didPanWith(gestureRecognizer: gestureRecognizer)
}
default:
// 2.
if self.transitionController.isInteractive {
self.transitionController.didPanWith(gestureRecognizer: gestureRecognizer)
}
}
}

Summary

Although it may be difficult to understand since all the source code is not included in the article, please try actually running the sample code.
It is a result of trial and error to realize this transition and interaction, so I think that it will be helpful.

masamichiueta/FluidPhoto

Thank you!

If you like it, please follow me @masamichiueta

--

--