Sharing an Image between two viewControllers during a transition animation

It's probably two different views and an animated snapshot view. In fact, this is exactly why snapshot views were invented.

That's how I do it in my app. Watch the movement of the red rectangle as the presented view slides up and down:

enter image description here

It looks like the red view is leaving the first view controller and entering the second view controller, but it's just an illusion. If you have a custom transition animation, you can add extra views during the transition. So I create a snapshot view that looks just like the first view, hide the real first view, and move the snapshot view — and then remove the snapshot view and show the real second view.

Same thing here (such a good trick that I use it in a lot of apps): it looks like the title has come loose from the first view controller table view cell and slid up to into the second view controller, but it's just a snapshot view:

enter image description here


Here is what we did in order to achieve floating screenshot of the view during animated transition (Swift 4):

Idea behind:

  1. Source and destination view controllers conforms to InterViewAnimatable protocol. We are using this protocol to find source and destination views.
  2. Then we creating snapshots of those views and hiding them. Instead, at the same position snapshots are shown.
  3. Then we animating snapshots.
  4. At the end of transition we unhiding destination view.

As result it looks like source view is flying to destination.

File: InterViewAnimation.swift

// TODO: In case of multiple views, add another property which will return some unique string (identifier).
protocol InterViewAnimatable {
   var targetView: UIView { get }
}

class InterViewAnimation: NSObject {

   var transitionDuration: TimeInterval = 0.25
   var isPresenting: Bool = false
}

extension InterViewAnimation: UIViewControllerAnimatedTransitioning {

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

   func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {

      let containerView = transitionContext.containerView

      guard
         let fromVC = transitionContext.viewController(forKey: .from),
         let toVC = transitionContext.viewController(forKey: .to) else {
            transitionContext.completeTransition(false)
            return
      }

      guard let fromTargetView = targetView(in: fromVC), let toTargetView = targetView(in: toVC) else {
         transitionContext.completeTransition(false)
         return
      }

      guard let fromImage = fromTargetView.caSnapshot(), let toImage = toTargetView.caSnapshot() else {
         transitionContext.completeTransition(false)
         return
      }

      let fromImageView = ImageView(image: fromImage)
      fromImageView.clipsToBounds = true

      let toImageView = ImageView(image: toImage)
      toImageView.clipsToBounds = true

      let startFrame = fromTargetView.frameIn(containerView)
      let endFrame = toTargetView.frameIn(containerView)

      fromImageView.frame = startFrame
      toImageView.frame = startFrame

      let cleanupClosure: () -> Void = {
         fromTargetView.isHidden = false
         toTargetView.isHidden = false
         fromImageView.removeFromSuperview()
         toImageView.removeFromSuperview()
      }

      let updateFrameClosure: () -> Void = {
         // https://stackoverflow.com/a/27997678/1418981
         // In order to have proper layout. Seems mostly needed when presenting.
         // For instance during presentation, destination view does'n account navigation bar height.
         toVC.view.setNeedsLayout()
         toVC.view.layoutIfNeeded()

         // Workaround wrong origin due ongoing layout process.
         let updatedEndFrame = toTargetView.frameIn(containerView)
         let correctedEndFrame = CGRect(origin: updatedEndFrame.origin, size: endFrame.size)
         fromImageView.frame = correctedEndFrame
         toImageView.frame = correctedEndFrame
      }

      let alimationBlock: (() -> Void)
      let completionBlock: ((Bool) -> Void)

      fromTargetView.isHidden = true
      toTargetView.isHidden = true

      if isPresenting {
         guard let toView = transitionContext.view(forKey: .to) else {
            transitionContext.completeTransition(false)
            assertionFailure()
            return
         }
         containerView.addSubviews(toView, toImageView, fromImageView)
         toView.frame = CGRect(origin: .zero, size: containerView.bounds.size)
         toView.alpha = 0
         alimationBlock = {
            toView.alpha = 1
            fromImageView.alpha = 0
            updateFrameClosure()
         }
         completionBlock = { _ in
            let success = !transitionContext.transitionWasCancelled
            if !success {
               toView.removeFromSuperview()
            }
            cleanupClosure()
            transitionContext.completeTransition(success)
         }
      } else {
         guard let fromView = transitionContext.view(forKey: .from) else {
            transitionContext.completeTransition(false)
            assertionFailure()
            return
         }
         containerView.addSubviews(toImageView, fromImageView)
         alimationBlock = {
            fromView.alpha = 0
            fromImageView.alpha = 0
            updateFrameClosure()
         }
         completionBlock = { _ in
            let success = !transitionContext.transitionWasCancelled
            if success {
               fromView.removeFromSuperview()
            }
            cleanupClosure()
            transitionContext.completeTransition(success)
         }
      }

      // TODO: Add more precise animation (i.e. Keyframe)
      if isPresenting {
         UIView.animate(withDuration: transitionDuration, delay: 0, options: .curveEaseIn,
                        animations: alimationBlock, completion: completionBlock)
      } else {
         UIView.animate(withDuration: transitionDuration, delay: 0, options: .curveEaseOut,
                        animations: alimationBlock, completion: completionBlock)
      }
   }
}

extension InterViewAnimation {

   private func targetView(in viewController: UIViewController) -> UIView? {
      if let view = (viewController as? InterViewAnimatable)?.targetView {
         return view
      }
      if let nc = viewController as? UINavigationController, let vc = nc.topViewController,
         let view = (vc as? InterViewAnimatable)?.targetView {
         return view
      }
      return nil
   }
}

Usage:

let sampleImage = UIImage(data: try! Data(contentsOf: URL(string: "https://placekitten.com/320/240")!))

class InterViewAnimationMasterViewController: StackViewController {

   private lazy var topView = View().autolayoutView()
   private lazy var middleView = View().autolayoutView()
   private lazy var bottomView = View().autolayoutView()

   private lazy var imageView = ImageView(image: sampleImage).autolayoutView()
   private lazy var actionButton = Button().autolayoutView()

   override func setupHandlers() {
      actionButton.setTouchUpInsideHandler { [weak self] in
         let vc = InterViewAnimationDetailsViewController()
         let nc = UINavigationController(rootViewController: vc)
         nc.modalPresentationStyle = .custom
         nc.transitioningDelegate = self
         vc.handleClose = { [weak self] in
            self?.dismissAnimated()
         }
         // Workaround for not up to date laout during animated transition.
         nc.view.setNeedsLayout()
         nc.view.layoutIfNeeded()
         vc.view.setNeedsLayout()
         vc.view.layoutIfNeeded()
         self?.presentAnimated(nc)
      }
   }

   override func setupUI() {
      stackView.addArrangedSubviews(topView, middleView, bottomView)
      stackView.distribution = .fillEqually

      bottomView.addSubviews(imageView, actionButton)

      topView.backgroundColor = UIColor.red.withAlphaComponent(0.5)
      middleView.backgroundColor = UIColor.green.withAlphaComponent(0.5)

      bottomView.layoutMargins = UIEdgeInsets(horizontal: 15, vertical: 15)
      bottomView.backgroundColor = UIColor.yellow.withAlphaComponent(0.5)

      actionButton.title = "Tap to perform Transition!"
      actionButton.contentEdgeInsets = UIEdgeInsets(dimension: 8)
      actionButton.backgroundColor = .magenta

      imageView.layer.borderWidth = 2
      imageView.layer.borderColor = UIColor.magenta.withAlphaComponent(0.5).cgColor
   }

   override func setupLayout() {
      var constraints = LayoutConstraint.PinInSuperView.atCenter(imageView)
      constraints += [
         LayoutConstraint.centerX(viewA: bottomView, viewB: actionButton),
         LayoutConstraint.pinBottoms(viewA: bottomView, viewB: actionButton)
      ]
      let size = sampleImage?.size.scale(by: 0.5) ?? CGSize()
      constraints += LayoutConstraint.constrainSize(view: imageView, size: size)
      NSLayoutConstraint.activate(constraints)
   }
}

extension InterViewAnimationMasterViewController: InterViewAnimatable {
   var targetView: UIView {
      return imageView
   }
}

extension InterViewAnimationMasterViewController: UIViewControllerTransitioningDelegate {

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

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

class InterViewAnimationDetailsViewController: StackViewController {

   var handleClose: (() -> Void)?

   private lazy var imageView = ImageView(image: sampleImage).autolayoutView()
   private lazy var bottomView = View().autolayoutView()

   override func setupUI() {
      stackView.addArrangedSubviews(imageView, bottomView)
      stackView.distribution = .fillEqually

      imageView.contentMode = .scaleAspectFit
      imageView.layer.borderWidth = 2
      imageView.layer.borderColor = UIColor.purple.withAlphaComponent(0.5).cgColor

      bottomView.backgroundColor = UIColor.blue.withAlphaComponent(0.5)

      navigationItem.leftBarButtonItem = BarButtonItem(barButtonSystemItem: .done) { [weak self] in
         self?.handleClose?()
      }
   }
}

extension InterViewAnimationDetailsViewController: InterViewAnimatable {
   var targetView: UIView {
      return imageView
   }
}

Reusable extensions:

extension UIView {

   // https://medium.com/@joesusnick/a-uiview-extension-that-will-teach-you-an-important-lesson-about-frames-cefe1e4beb0b
   public func frameIn(_ view: UIView?) -> CGRect {
      if let superview = superview {
         return superview.convert(frame, to: view)
      }
      return frame
   }
}


extension UIView {

   /// The method drawViewHierarchyInRect:afterScreenUpdates: performs its operations on the GPU as much as possible
   /// In comparison, the method renderInContext: performs its operations inside of your app’s address space and does
   /// not use the GPU based process for performing the work.
   /// https://stackoverflow.com/a/25704861/1418981
   public func caSnapshot(scale: CGFloat = 0, isOpaque: Bool = false) -> UIImage? {
      var isSuccess = false
      UIGraphicsBeginImageContextWithOptions(bounds.size, isOpaque, scale)
      if let context = UIGraphicsGetCurrentContext() {
         layer.render(in: context)
         isSuccess = true
      }
      let image = UIGraphicsGetImageFromCurrentImageContext()
      UIGraphicsEndImageContext()
      return isSuccess ? image : nil
   }
}

Result (gif animation):

Gif animation