I have came across really cool transitions between viewControllers since UIViewControllerAnimatedTransitioning protocol was made available in IOS 7. Recently I noticed a particularly interesting one in Intacart's IOS app.
Here is the animation I am talking about in slow motion:
https://www.dropbox.com/s/p2hxj45ycq18i3l/Video%20Oct%2015%2C%207%2023%2059%20PM.mov?dl=0
First I thought it was similar to what the author walks through in this tutorial, with some extra fade-in and fade-out animations: http://www.raywenderlich.com/113845/ios-animation-tutorial-custom-view-controller-presentation-transitions
But then if you look at it closely, it seems like the product image transitions between the two viewControllers as the first viewController fades out. The reason why I think there are two viewControllers is because when you swipe the new view down, you can still see the original view behind it with no layout changes.
Maybe two viewControllers actually have the product image (not faded out) and are somehow animating at the same time with perfect precision and one of them fades in as the other fades out.
What do you think is actually going on there?
How is it possible to program such a transition animation that it looks like an image is shared between two viewControllers?
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:
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:
Here is what we did in order to achieve floating screenshot of the view during animated transition (Swift 4):
Idea behind:
- Source and destination view controllers conforms to
InterViewAnimatable
protocol. We are using this protocol to find source and destination views.
- Then we creating snapshots of those views and hiding them. Instead, at the same position snapshots are shown.
- Then we animating snapshots.
- 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):