Merge branch 'release-candidate' into stable
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 00e03f0..5ce34ea 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,58 @@
+# 3.2.0
+
+This minor release introduces new features for presentation, view snapshotting, and defered transition work. There is also a new photo album example demonstrating how to build a contextual transition in which the context may change.
+
+## New features
+
+Transition context now has a `deferToCompletion:` API for deferring work to the completion of the transition.
+
+```swift
+// Example (Swift):
+foreImageView.isHidden = true
+context.defer {
+  foreImageView.isHidden = false
+}
+```
+
+`MDMTransitionPresentationController` is a presentation controller that supports presenting view controllers at custom frames and showing an overlay scrim view.
+
+The new `MDMTransitionViewSnapshotter` class can be used to create and manage snapshot views during a transition.
+
+```swift
+let snapshotter = TransitionViewSnapshotter(containerView: context.containerView)
+context.defer {
+  snapshotter.removeAllSnapshots()
+}
+
+let snapshotView = snapshotter.snapshot(of: view, isAppearing: context.direction == .forward)
+```
+
+## Source changes
+
+* [Add a snapshotting API and contextual transition example (#37)](https://github.com/material-motion/transitioning-objc/commit/a6ae314ddd5ff4e6f0ca9a8711348f8682d95e66) (featherless)
+* [Store the presentation controller as a weak reference. (#34)](https://github.com/material-motion/transitioning-objc/commit/9f73e70e382ef8291f3ad85f7ccac25994f06e43) (featherless)
+* [Add a stock presentation controller implementation. (#35)](https://github.com/material-motion/transitioning-objc/commit/6c98fa24f7e733262dc802b1e7c6b30134a29936) (featherless)
+* [Minor formatting adjustment.](https://github.com/material-motion/transitioning-objc/commit/28f6e2e72534c8e0e77b60a98140be3bc06cd37a) (Jeff Verkoeyen)
+
+## API changes
+
+## MDMTransitionContext
+
+*new* method: `deferToCompletion:`. Defers execution of the provided work until the completion of the transition.
+
+## MDMTransitionPresentationController
+
+*new* class: `MDMTransitionPresentationController`. A transition presentation controller implementation that supports animation delegation, a darkened overlay view, and custom presentation frames.
+
+## MDMTransitionViewSnapshotter
+
+*new* class: `MDMTransitionViewSnapshotter`. A view snapshotter creates visual replicas of views so that they may be animated during a transition without adversely affecting the original view hierarchy.
+
+## Non-source changes
+
+* [Add photo album example. (#38)](https://github.com/material-motion/transitioning-objc/commit/a1d49a6f432b7fddf8d15c90a5ea185fd8e03c5a) (featherless)
+* [Add some organization to the transition examples. (#36)](https://github.com/material-motion/transitioning-objc/commit/27756b1e578cb8be3fa6d727a3aefafe9b1aa496) (featherless)
+
 # 3.1.0
 
 This minor release resolves a build warning and introduces the ability to customize navigation
diff --git a/MotionTransitioning.podspec b/MotionTransitioning.podspec
index 476efb7..47a0047 100644
--- a/MotionTransitioning.podspec
+++ b/MotionTransitioning.podspec
@@ -1,7 +1,7 @@
 Pod::Spec.new do |s|
   s.name         = "MotionTransitioning"
   s.summary      = "Light-weight API for building UIViewController transitions."
-  s.version      = "3.1.0"
+  s.version      = "3.2.0"
   s.authors      = "The Material Motion Authors"
   s.license      = "Apache 2.0"
   s.homepage     = "https://github.com/material-motion/transitioning-objc"
diff --git a/Podfile.lock b/Podfile.lock
index 245d219..370b6a5 100644
--- a/Podfile.lock
+++ b/Podfile.lock
@@ -1,6 +1,6 @@
 PODS:
   - CatalogByConvention (2.1.1)
-  - MotionTransitioning (3.1.0)
+  - MotionTransitioning (3.2.0)
 
 DEPENDENCIES:
   - CatalogByConvention
@@ -12,7 +12,7 @@
 
 SPEC CHECKSUMS:
   CatalogByConvention: c3a5319de04250a7cd4649127fcfca5fe3322a43
-  MotionTransitioning: 5a4188866a5b016f7181dd41c1a14f93809688ec
+  MotionTransitioning: 93ff3fcc6a597786a01ace3232109e5075c57526
 
 PODFILE CHECKSUM: db2e7ac8d9d65704a2cbffa0b77e39a574cb7248
 
diff --git a/examples/ContextualExample.swift b/examples/ContextualExample.swift
new file mode 100644
index 0000000..bdff87d
--- /dev/null
+++ b/examples/ContextualExample.swift
@@ -0,0 +1,154 @@
+/*
+ Copyright 2017-present The Material Motion Authors. All Rights Reserved.
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+
+import UIKit
+import MotionTransitioning
+
+// This example demonstrates how to build a contextual transition.
+
+class ContextualExampleViewController: ExampleViewController {
+
+  func didTap(_ tapGesture: UITapGestureRecognizer) {
+    let controller = DestinationViewController()
+
+    // A contextual transition is provided with information relevant to the transition, such as the
+    // view that is being expanded/collapsed. This information can be provided at initialization
+    // time if it is unlikely to ever change (e.g. a static view on the screen as in this example).
+    //
+    // If it's possible for the context to change, then a delegate pattern is a preferred solution
+    // because it will allow the delegate to request the new context each time the transition
+    // begins. This can be helpful in building photo album transitions, for example.
+    //
+    // Note that in this example we're populating the contextual transition with the tapped view.
+    // Our rudimentary transition will animate the context view to the center of the screen from its
+    // current location.
+    controller.transitionController.transition = ContextualTransition(contextView: tapGesture.view!)
+
+    present(controller, animated: true)
+  }
+
+  override func viewDidLoad() {
+    super.viewDidLoad()
+
+    let square = UIView(frame: .init(x: 16, y: 200, width: 128, height: 128))
+    square.autoresizingMask = [.flexibleLeftMargin, .flexibleTopMargin,
+                               .flexibleRightMargin, .flexibleBottomMargin]
+    square.backgroundColor = .blue
+    view.addSubview(square)
+
+    let circle = UIView(frame: .init(x: 64, y: 400, width: 128, height: 128))
+    circle.autoresizingMask = [.flexibleLeftMargin, .flexibleTopMargin,
+                               .flexibleRightMargin, .flexibleBottomMargin]
+    circle.backgroundColor = .red
+    view.addSubview(circle)
+
+    square.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTap(_:))))
+    circle.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTap(_:))))
+  }
+
+  override func exampleInformation() -> ExampleInfo {
+    return .init(title: type(of: self).catalogBreadcrumbs().last!,
+                 instructions: "Tap to present a modal transition.")
+  }
+}
+
+private class ContextualTransition: NSObject, Transition {
+
+  // Store the context for the lifetime of the transition.
+  let contextView: UIView
+  init(contextView: UIView) {
+    self.contextView = contextView
+  }
+
+  func start(with context: TransitionContext) {
+    // A small helper function for creating bi-directional animations.
+    // See https://github.com/material-motion/motion-animator-objc for a more versatile
+    // bidirectional Core Animation implementation.
+    let addAnimationToLayer: (CABasicAnimation, CALayer) -> Void = { animation, layer in
+      if context.direction == .backward {
+        let swap = animation.fromValue
+        animation.fromValue = animation.toValue
+        animation.toValue = swap
+      }
+      layer.add(animation, forKey: animation.keyPath)
+      layer.setValue(animation.toValue, forKeyPath: animation.keyPath!)
+    }
+
+    let snapshotter = TransitionViewSnapshotter(containerView: context.containerView)
+    context.defer {
+      snapshotter.removeAllSnapshots()
+    }
+
+    CATransaction.begin()
+    CATransaction.setCompletionBlock {
+      context.transitionDidEnd()
+    }
+
+    let fadeIn = CABasicAnimation(keyPath: "opacity")
+    fadeIn.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
+    fadeIn.fromValue = 0
+    fadeIn.toValue = 1
+    addAnimationToLayer(fadeIn, context.foreViewController.view.layer)
+
+    // We use a snapshot view to accomplish two things:
+    // 1) To not affect the context view's state.
+    // 2) To allow our context view to appear in front of the fore view controller's view.
+    //
+    // The provided view snapshotter will automatically hide the snapshotted view and remove the
+    // snapshot view upon completion of the transition.
+    let snapshotContextView = snapshotter.snapshot(of: contextView,
+                                                   isAppearing: context.direction == .backward)
+
+    let expand = CABasicAnimation(keyPath: "transform.scale.xy")
+    expand.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
+    expand.fromValue = 1
+    expand.toValue = 2
+    addAnimationToLayer(expand, snapshotContextView.layer)
+
+    let shift = CASpringAnimation(keyPath: "position")
+    shift.damping = 500
+    shift.stiffness = 1000
+    shift.mass = 3
+    shift.duration = 0.5
+    shift.fromValue = snapshotContextView.layer.position
+    shift.toValue = CGPoint(x: context.foreViewController.view.bounds.midX,
+                            y: context.foreViewController.view.bounds.midY)
+    addAnimationToLayer(shift, snapshotContextView.layer)
+
+    let fadeOut = CABasicAnimation(keyPath: "opacity")
+    fadeOut.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
+    fadeOut.fromValue = 1
+    fadeOut.toValue = 0
+    addAnimationToLayer(fadeOut, snapshotContextView.layer)
+
+    CATransaction.commit()
+  }
+}
+
+private class DestinationViewController: ExampleViewController {
+
+  override func viewDidLoad() {
+    super.viewDidLoad()
+
+    view.backgroundColor = .primaryColor
+
+    view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTap)))
+  }
+
+  func didTap() {
+    dismiss(animated: true)
+  }
+}
diff --git a/examples/CustomPresentationExample.swift b/examples/CustomPresentationExample.swift
index c3d3b45..a72fb58 100644
--- a/examples/CustomPresentationExample.swift
+++ b/examples/CustomPresentationExample.swift
@@ -25,6 +25,8 @@
   override init(style: UITableViewStyle) {
     super.init(style: style)
 
+    transitions = []
+
     // Aside: we're using a simple model pattern here to define the data for the different
     // transitions up separate from their presentation. Check out the `didSelectRowAt`
     // implementation to see how we're ultimately presenting the modal view controller.
@@ -68,7 +70,7 @@
 
   // When provided, the transition will use a presentation controller to customize the presentation
   // of the transition.
-  var calculateFrameOfPresentedViewInContainerView: CalculateFrame?
+  var calculateFrameOfPresentedViewInContainerView: TransitionFrameCalculation?
 
   func start(with context: TransitionContext) {
     CATransaction.begin()
@@ -122,104 +124,14 @@
                               presenting: UIViewController,
                               source: UIViewController?) -> UIPresentationController? {
     if let calculateFrameOfPresentedViewInContainerView = calculateFrameOfPresentedViewInContainerView {
-      return DimmingPresentationController(presentedViewController: presented,
-                                           presenting: presenting,
-                                           calculateFrameOfPresentedViewInContainerView: calculateFrameOfPresentedViewInContainerView)
+      return TransitionPresentationController(presentedViewController: presented,
+                                              presenting: presenting,
+                                              calculateFrameOfPresentedView: calculateFrameOfPresentedViewInContainerView)
     }
     return nil
   }
 }
 
-// What follows is a fairly typical presentation controller implementation that adds a dimming view
-// and fades the dimming view in/out during the transition.
-//
-// Note that we've conformed to the Transition type: this allows the presentation controller to
-// add any custom animations during the transition. The presentation controller's `start` method
-// will be invoked before the Transition object's `start` method.
-
-final class DimmingPresentationController: UIPresentationController {
-
-  init(presentedViewController: UIViewController,
-              presenting presentingViewController: UIViewController,
-              calculateFrameOfPresentedViewInContainerView: @escaping CalculateFrame) {
-    let dimmingView = UIView()
-    dimmingView.backgroundColor = UIColor(white: 0, alpha: 0.3)
-    dimmingView.alpha = 0
-    dimmingView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
-    self.dimmingView = dimmingView
-
-    self.calculateFrameOfPresentedViewInContainerView = calculateFrameOfPresentedViewInContainerView
-
-    super.init(presentedViewController: presentedViewController, presenting: presentingViewController)
-  }
-
-  override var frameOfPresentedViewInContainerView: CGRect {
-    // We delegate out our frame calculation here:
-    return calculateFrameOfPresentedViewInContainerView(self)
-  }
-
-  override func presentationTransitionWillBegin() {
-    guard let containerView = containerView else { return }
-
-    dimmingView.frame = containerView.bounds
-    containerView.insertSubview(dimmingView, at: 0)
-
-    // This autoresizing mask assumes that the calculated frame is centered in the screen. This
-    // assumption won't hold true if the frame is aligned to a particular edge. We could improve
-    // this implementation by allowing the creator of the transition to customize the
-    // autoresizingMask in some manner.
-    presentedViewController.view.autoresizingMask = [.flexibleLeftMargin,
-                                                     .flexibleTopMargin,
-                                                     .flexibleRightMargin,
-                                                     .flexibleBottomMargin]
-  }
-
-  override func presentationTransitionDidEnd(_ completed: Bool) {
-    if !completed {
-      dimmingView.removeFromSuperview()
-    }
-  }
-
-  override func dismissalTransitionWillBegin() {
-    // We fall back to an alongside fade out when there is no active transition instance because
-    // our start implementation won't be invoked in this case.
-    if presentedViewController.transitionController.activeTransition == nil {
-      presentedViewController.transitionCoordinator?.animate(alongsideTransition: { context in
-        self.dimmingView.alpha = 0
-      })
-    }
-  }
-
-  override func dismissalTransitionDidEnd(_ completed: Bool) {
-    if completed {
-      dimmingView.removeFromSuperview()
-    } else {
-      dimmingView.alpha = 1
-    }
-  }
-
-  private let calculateFrameOfPresentedViewInContainerView: CalculateFrame
-  fileprivate let dimmingView: UIView
-}
-
-extension DimmingPresentationController: Transition {
-  func start(with context: TransitionContext) {
-    let fade = CABasicAnimation(keyPath: "opacity")
-    fade.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
-    fade.fromValue = 0
-    fade.toValue = 1
-    if context.direction == .backward {
-      let swap = fade.fromValue
-      fade.fromValue = fade.toValue
-      fade.toValue = swap
-    }
-    dimmingView.layer.add(fade, forKey: fade.keyPath)
-    dimmingView.layer.setValue(fade.toValue, forKeyPath: fade.keyPath!)
-  }
-}
-
-typealias CalculateFrame = (UIPresentationController) -> CGRect
-
 // MARK: Supplemental code
 
 extension CustomPresentationExampleViewController {
diff --git a/examples/FadeExample.m b/examples/FadeExample.m
index 799164f..3f494d9 100644
--- a/examples/FadeExample.m
+++ b/examples/FadeExample.m
@@ -18,20 +18,25 @@
 
 #import "TransitionsCatalog-Swift.h"
 
-// This example demonstrates the minimal path to building a custom transition using the Material
-// Motion Transitioning APIs in Objective-C. Please see the companion Swift implementation for
-// detailed comments.
-
-@interface FadeTransition : NSObject <MDMTransition>
-@end
+// This example demonstrates the minimal path to using a custom transition in Objective-C.
 
 @implementation FadeExampleObjcViewController
 
 - (void)didTap {
   ModalViewController *viewController = [[ModalViewController alloc] init];
 
+  // The transition controller is an associated object on all UIViewController instances that
+  // allows you to customize the way the view controller is presented. The primary API on the
+  // controller that you'll make use of is the `transition` property. Setting this property will
+  // dictate how the view controller is presented. For this example we've built a custom
+  // FadeTransition, so we'll make use of that now:
   viewController.mdm_transitionController.transition = [[FadeTransition alloc] init];
 
+  // Note that once we assign the transition object to the view controller, the transition will
+  // govern all subsequent presentations and dismissals of that view controller instance. If we
+  // want to use a different transition (e.g. to use an edge-swipe-to-dismiss transition) then we
+  // can simply change the transition object before initiating the transition.
+
   [self presentViewController:viewController animated:true completion:nil];
 }
 
@@ -57,36 +62,3 @@
 }
 
 @end
-
-@implementation FadeTransition
-
-- (NSTimeInterval)transitionDurationWithContext:(nonnull id<MDMTransitionContext>)context {
-  return 0.3;
-}
-
-- (void)startWithContext:(id<MDMTransitionContext>)context {
-  [CATransaction begin];
-  [CATransaction setCompletionBlock:^{
-    [context transitionDidEnd];
-  }];
-
-  CABasicAnimation *fade = [CABasicAnimation animationWithKeyPath:@"opacity"];
-
-  fade.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
-
-  fade.fromValue = @0;
-  fade.toValue = @1;
-
-  if (context.direction == MDMTransitionDirectionBackward) {
-    id swap = fade.fromValue;
-    fade.fromValue = fade.toValue;
-    fade.toValue = swap;
-  }
-
-  [context.foreViewController.view.layer addAnimation:fade forKey:fade.keyPath];
-  [context.foreViewController.view.layer setValue:fade.toValue forKey:fade.keyPath];
-
-  [CATransaction commit];
-}
-
-@end
diff --git a/examples/FadeExample.swift b/examples/FadeExample.swift
index 460bfda..1324d3e 100644
--- a/examples/FadeExample.swift
+++ b/examples/FadeExample.swift
@@ -17,8 +17,8 @@
 import UIKit
 import MotionTransitioning
 
-// This example demonstrates the minimal path to building a custom transition using the Motion
-// Transitioning APIs in Swift. The essential steps have been documented below.
+// This example demonstrates the minimal path to using a custom transition in Swift. See
+// FadeTransition.swift for the custom transition implementation.
 
 class FadeExampleViewController: ExampleViewController {
 
@@ -59,41 +59,3 @@
                  instructions: "Tap to present a modal transition.")
   }
 }
-
-// Transitions must be NSObject types that conform to the Transition protocol.
-private final class FadeTransition: NSObject, Transition {
-
-  // The sole method we're expected to implement, start is invoked each time the view controller is
-  // presented or dismissed.
-  func start(with context: TransitionContext) {
-    CATransaction.begin()
-
-    CATransaction.setCompletionBlock {
-      // Let UIKit know that the transition has come to an end.
-      context.transitionDidEnd()
-    }
-
-    let fade = CABasicAnimation(keyPath: "opacity")
-
-    fade.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
-
-    // Define our animation assuming that we're going forward (presenting)...
-    fade.fromValue = 0
-    fade.toValue = 1
-
-    // ...and reverse it if we're going backwards (dismissing).
-    if context.direction == .backward {
-      let swap = fade.fromValue
-      fade.fromValue = fade.toValue
-      fade.toValue = swap
-    }
-
-    // Add the animation...
-    context.foreViewController.view.layer.add(fade, forKey: fade.keyPath)
-
-    // ...and ensure that our model layer reflects the final value.
-    context.foreViewController.view.layer.setValue(fade.toValue, forKeyPath: fade.keyPath!)
-
-    CATransaction.commit()
-  }
-}
diff --git a/examples/NavControllerFadeExample.swift b/examples/NavControllerFadeExample.swift
index ddeedfa..6525613 100644
--- a/examples/NavControllerFadeExample.swift
+++ b/examples/NavControllerFadeExample.swift
@@ -75,41 +75,3 @@
                  instructions: "Tap to present a modal transition.")
   }
 }
-
-// Transitions must be NSObject types that conform to the Transition protocol.
-private final class FadeTransition: NSObject, Transition {
-
-  // The sole method we're expected to implement, start is invoked each time the view controller is
-  // presented or dismissed.
-  func start(with context: TransitionContext) {
-    CATransaction.begin()
-
-    CATransaction.setCompletionBlock {
-      // Let UIKit know that the transition has come to an end.
-      context.transitionDidEnd()
-    }
-
-    let fade = CABasicAnimation(keyPath: "opacity")
-
-    fade.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
-
-    // Define our animation assuming that we're going forward (presenting)...
-    fade.fromValue = 0
-    fade.toValue = 1
-
-    // ...and reverse it if we're going backwards (dismissing).
-    if context.direction == .backward {
-      let swap = fade.fromValue
-      fade.fromValue = fade.toValue
-      fade.toValue = swap
-    }
-
-    // Add the animation...
-    context.foreViewController.view.layer.add(fade, forKey: fade.keyPath)
-
-    // ...and ensure that our model layer reflects the final value.
-    context.foreViewController.view.layer.setValue(fade.toValue, forKeyPath: fade.keyPath!)
-
-    CATransaction.commit()
-  }
-}
diff --git a/examples/PhotoAlbumExample.swift b/examples/PhotoAlbumExample.swift
new file mode 100644
index 0000000..fdbd3ae
--- /dev/null
+++ b/examples/PhotoAlbumExample.swift
@@ -0,0 +1,338 @@
+/*
+ Copyright 2017-present The Material Motion Authors. All Rights Reserved.
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+
+import UIKit
+import MotionTransitioning
+
+// This example demonstrates how to build a photo album contextual transition.
+
+let numberOfImageAssets = 10
+let numberOfPhotosInAlbum = 30
+
+struct Photo {
+  let name: String
+  let image: UIImage
+  let uuid: String
+
+  fileprivate init(name: String) {
+    self.uuid = NSUUID().uuidString
+    self.name = name
+
+    // NOTE: In a real app you should never load images from disk on the UI thread like this.
+    // Instead, you should find some way to cache the thumbnails in memory and then asynchronously
+    // load the full-size photos from disk/network when needed. The photo library APIs provide
+    // exactly this sort of behavior (square thumbnails are accessible immediately on the UI thread
+    // while the full-sized photos need to be loaded asynchronously).
+    self.image = UIImage(named: "\(self.name).jpg")!
+  }
+}
+
+class PhotoAlbum {
+  let photos: [Photo]
+  let identifierToIndex: [String: Int]
+
+  init() {
+    var photos: [Photo] = []
+    var identifierToIndex: [String: Int] = [:]
+    for index in 0..<numberOfPhotosInAlbum {
+      let photo = Photo(name: "image\(index % numberOfImageAssets)")
+      photos.append(photo)
+      identifierToIndex[photo.uuid] = index
+    }
+    self.photos = photos
+    self.identifierToIndex = identifierToIndex
+  }
+}
+
+private let photoCellIdentifier = "photoCell"
+
+private class PhotoCollectionViewCell: UICollectionViewCell {
+  let imageView = UIImageView()
+
+  override init(frame: CGRect) {
+    super.init(frame: frame)
+
+    imageView.contentMode = .scaleAspectFill
+    imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
+    imageView.frame = bounds
+    imageView.clipsToBounds = true
+
+    contentView.addSubview(imageView)
+  }
+
+  required init?(coder aDecoder: NSCoder) {
+    fatalError("init(coder:) has not been implemented")
+  }
+}
+
+public class PhotoAlbumExampleViewController: UICollectionViewController, PhotoAlbumTransitionDelegate {
+
+  let album = PhotoAlbum()
+
+  init() {
+    super.init(collectionViewLayout: UICollectionViewFlowLayout())
+  }
+
+  public required init?(coder aDecoder: NSCoder) {
+    fatalError("init(coder:) has not been implemented")
+  }
+
+  override public func viewDidLoad() {
+    super.viewDidLoad()
+
+    collectionView!.backgroundColor = .white
+    collectionView!.register(PhotoCollectionViewCell.self,
+                             forCellWithReuseIdentifier: photoCellIdentifier)
+  }
+
+  public override func viewDidLayoutSubviews() {
+    super.viewDidLayoutSubviews()
+
+    updateLayout()
+  }
+
+  func updateLayout() {
+    let layout = collectionView!.collectionViewLayout as! UICollectionViewFlowLayout
+    layout.sectionInset = .init(top: 4, left: 4, bottom: 4, right: 4)
+    layout.minimumInteritemSpacing = 4
+    layout.minimumLineSpacing = 4
+
+    let numberOfColumns: CGFloat = 3
+    let squareDimension = (view.bounds.width - layout.sectionInset.left - layout.sectionInset.right - (numberOfColumns - 1) * layout.minimumInteritemSpacing) / numberOfColumns
+    layout.itemSize = CGSize(width: squareDimension, height: squareDimension)
+  }
+
+  public override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
+    return album.photos.count
+  }
+
+  public override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
+    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: photoCellIdentifier,
+                                                  for: indexPath) as! PhotoCollectionViewCell
+    let photo = album.photos[indexPath.row]
+    cell.imageView.image = photo.image
+    return cell
+  }
+
+  public override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
+    let viewController = PhotoAlbumViewController(album: album)
+    viewController.currentPhoto = album.photos[indexPath.row]
+    viewController.transitionController.transition = PhotoAlbumTransition(delegate: self)
+    present(viewController, animated: true)
+  }
+
+  fileprivate func contextView(forAlbumViewController: PhotoAlbumViewController) -> UIImageView? {
+    let currentPhoto = forAlbumViewController.currentPhoto
+    guard let photoIndex = album.identifierToIndex[currentPhoto.uuid] else {
+      return nil
+    }
+    let photoIndexPath = IndexPath(item: photoIndex, section: 0)
+    if collectionView?.cellForItem(at: photoIndexPath) == nil {
+      collectionView?.scrollToItem(at: photoIndexPath, at: .top, animated: false)
+      collectionView?.reloadItems(at: [photoIndexPath])
+    }
+    guard let cell = collectionView?.cellForItem(at: photoIndexPath) as? PhotoCollectionViewCell else {
+      return nil
+    }
+    return cell.imageView
+  }
+}
+
+private class PhotoAlbumViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate {
+
+  var collectionView: UICollectionView!
+  var currentPhoto: Photo
+
+  let album: PhotoAlbum
+  init(album: PhotoAlbum) {
+    self.album = album
+    self.currentPhoto = self.album.photos.first!
+
+    super.init(nibName: nil, bundle: nil)
+  }
+
+  required init?(coder aDecoder: NSCoder) {
+    fatalError("init(coder:) has not been implemented")
+  }
+
+  override func viewDidLoad() {
+    super.viewDidLoad()
+
+    automaticallyAdjustsScrollViewInsets = false
+
+    let layout = UICollectionViewFlowLayout()
+    layout.itemSize = view.bounds.size
+    layout.minimumInteritemSpacing = 0
+    layout.minimumLineSpacing = 8
+    layout.footerReferenceSize = CGSize(width: layout.minimumLineSpacing / 2,
+                                        height: view.bounds.size.height)
+    layout.headerReferenceSize = layout.footerReferenceSize
+    layout.scrollDirection = .horizontal
+
+    collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
+    collectionView.isPagingEnabled = true
+    collectionView.backgroundColor = .backgroundColor
+    collectionView.showsHorizontalScrollIndicator = false
+    collectionView.dataSource = self
+    collectionView.delegate = self
+
+    collectionView.register(PhotoCollectionViewCell.self,
+                            forCellWithReuseIdentifier: photoCellIdentifier)
+
+    var extendedBounds = view.bounds
+    extendedBounds.size.width = extendedBounds.width + layout.minimumLineSpacing
+    collectionView.bounds = extendedBounds
+
+    view.addSubview(collectionView)
+  }
+
+  override func viewDidLayoutSubviews() {
+    super.viewDidLayoutSubviews()
+
+    collectionView.center = CGPoint(x: view.bounds.midX, y: view.bounds.midY)
+  }
+
+  override func viewWillAppear(_ animated: Bool) {
+    super.viewWillAppear(animated)
+
+    navigationController?.setNavigationBarHidden(true, animated: animated)
+
+    let photoIndex = album.photos.index { $0.image == currentPhoto.image }!
+    let indexPath = IndexPath(item: photoIndex, section: 0)
+    collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: false)
+  }
+
+  override var preferredStatusBarStyle: UIStatusBarStyle {
+    return .lightContent
+  }
+
+  func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
+    return album.photos.count
+  }
+
+  func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
+    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: photoCellIdentifier,
+                                                  for: indexPath) as! PhotoCollectionViewCell
+    let photo = album.photos[indexPath.row]
+    cell.imageView.image = photo.image
+    cell.imageView.contentMode = .scaleAspectFit
+    return cell
+  }
+
+  func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
+    dismiss(animated: true)
+  }
+
+  func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
+    currentPhoto = album.photos[indexPathForCurrentPhoto().item]
+  }
+  
+  func indexPathForCurrentPhoto() -> IndexPath {
+    return collectionView.indexPathsForVisibleItems.first!
+  }
+}
+
+private protocol PhotoAlbumTransitionDelegate {
+  func contextView(forAlbumViewController: PhotoAlbumViewController) -> UIImageView?
+}
+
+private class PhotoAlbumTransition: NSObject, Transition, TransitionWithFallback {
+
+  // Store the context for the lifetime of the transition.
+  let delegate: PhotoAlbumTransitionDelegate
+  init(delegate: PhotoAlbumTransitionDelegate) {
+    self.delegate = delegate
+  }
+
+  func fallbackTransition(with context: TransitionContext) -> Transition? {
+    if delegate.contextView(forAlbumViewController: context.foreViewController as! PhotoAlbumViewController) != nil {
+      return self
+    }
+    return nil
+  }
+
+  func start(with context: TransitionContext) {
+    guard let contextView = delegate.contextView(forAlbumViewController: context.foreViewController as! PhotoAlbumViewController) else {
+      return
+    }
+
+    // A small helper function for creating bi-directional animations.
+    // See https://github.com/material-motion/motion-animator-objc for a more versatile
+    // bidirectional Core Animation implementation.
+    let addAnimationToLayer: (CABasicAnimation, CALayer) -> Void = { animation, layer in
+      if context.direction == .backward {
+        let swap = animation.fromValue
+        animation.fromValue = animation.toValue
+        animation.toValue = swap
+      }
+      layer.add(animation, forKey: animation.keyPath)
+      layer.setValue(animation.toValue, forKeyPath: animation.keyPath!)
+    }
+
+    let snapshotter = TransitionViewSnapshotter(containerView: context.containerView)
+    context.defer {
+      snapshotter.removeAllSnapshots()
+    }
+
+    let foreVC = context.foreViewController as! PhotoAlbumViewController
+    let foreImageView = (foreVC.collectionView.cellForItem(at: foreVC.indexPathForCurrentPhoto()) as! PhotoCollectionViewCell).imageView
+    let imageSize = foreImageView.image!.size
+
+    let fitScale = min(foreImageView.bounds.width / imageSize.width,
+                       foreImageView.bounds.height / imageSize.height)
+    let fitSize = CGSize(width: fitScale * imageSize.width, height: fitScale * imageSize.height)
+
+    foreImageView.isHidden = true
+    context.defer {
+      foreImageView.isHidden = false
+    }
+
+    CATransaction.begin()
+    CATransaction.setCompletionBlock {
+      context.transitionDidEnd()
+    }
+
+    let fadeIn = CABasicAnimation(keyPath: "opacity")
+    fadeIn.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
+    fadeIn.fromValue = 0
+    fadeIn.toValue = 1
+    addAnimationToLayer(fadeIn, context.foreViewController.view.layer)
+
+    let snapshotContextView = snapshotter.snapshot(of: contextView,
+                                                   isAppearing: context.direction == .backward)
+
+    let shift = CASpringAnimation(keyPath: "position")
+    shift.damping = 500
+    shift.stiffness = 1000
+    shift.mass = 3
+    shift.duration = 0.5
+    shift.fromValue = snapshotContextView.layer.position
+    shift.toValue = CGPoint(x: context.foreViewController.view.bounds.midX,
+                            y: context.foreViewController.view.bounds.midY)
+    addAnimationToLayer(shift, snapshotContextView.layer)
+
+    let expansion = CASpringAnimation(keyPath: "bounds.size")
+    expansion.damping = 500
+    expansion.stiffness = 1000
+    expansion.mass = 3
+    expansion.duration = 0.5
+    expansion.fromValue = snapshotContextView.layer.bounds.size
+    expansion.toValue = fitSize
+    addAnimationToLayer(expansion, snapshotContextView.layer)
+
+    CATransaction.commit()
+  }
+}
diff --git a/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image0.imageset/Contents.json b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image0.imageset/Contents.json
new file mode 100644
index 0000000..0230f66
--- /dev/null
+++ b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image0.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+  "images" : [
+    {
+      "idiom" : "universal",
+      "scale" : "1x",
+      "filename" : "image0.jpg"
+    },
+    {
+      "idiom" : "universal",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "universal",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "version" : 1,
+    "author" : "xcode"
+  }
+}
\ No newline at end of file
diff --git a/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image0.imageset/image0.jpg b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image0.imageset/image0.jpg
new file mode 100644
index 0000000..60a9106
--- /dev/null
+++ b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image0.imageset/image0.jpg
Binary files differ
diff --git a/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image1.imageset/Contents.json b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image1.imageset/Contents.json
new file mode 100644
index 0000000..d528400
--- /dev/null
+++ b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image1.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+  "images" : [
+    {
+      "idiom" : "universal",
+      "scale" : "1x",
+      "filename" : "image1.jpg"
+    },
+    {
+      "idiom" : "universal",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "universal",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "version" : 1,
+    "author" : "xcode"
+  }
+}
\ No newline at end of file
diff --git a/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image1.imageset/image1.jpg b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image1.imageset/image1.jpg
new file mode 100644
index 0000000..dc14aea
--- /dev/null
+++ b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image1.imageset/image1.jpg
Binary files differ
diff --git a/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image2.imageset/Contents.json b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image2.imageset/Contents.json
new file mode 100644
index 0000000..4e4e933
--- /dev/null
+++ b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image2.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+  "images" : [
+    {
+      "idiom" : "universal",
+      "scale" : "1x",
+      "filename" : "image2.jpg"
+    },
+    {
+      "idiom" : "universal",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "universal",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "version" : 1,
+    "author" : "xcode"
+  }
+}
\ No newline at end of file
diff --git a/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image2.imageset/image2.jpg b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image2.imageset/image2.jpg
new file mode 100644
index 0000000..abd1217
--- /dev/null
+++ b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image2.imageset/image2.jpg
Binary files differ
diff --git a/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image3.imageset/Contents.json b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image3.imageset/Contents.json
new file mode 100644
index 0000000..e947521
--- /dev/null
+++ b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image3.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+  "images" : [
+    {
+      "idiom" : "universal",
+      "scale" : "1x",
+      "filename" : "image3.jpg"
+    },
+    {
+      "idiom" : "universal",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "universal",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "version" : 1,
+    "author" : "xcode"
+  }
+}
\ No newline at end of file
diff --git a/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image3.imageset/image3.jpg b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image3.imageset/image3.jpg
new file mode 100644
index 0000000..52b6fac
--- /dev/null
+++ b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image3.imageset/image3.jpg
Binary files differ
diff --git a/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image4.imageset/Contents.json b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image4.imageset/Contents.json
new file mode 100644
index 0000000..602e097
--- /dev/null
+++ b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image4.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+  "images" : [
+    {
+      "idiom" : "universal",
+      "scale" : "1x",
+      "filename" : "image4.jpg"
+    },
+    {
+      "idiom" : "universal",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "universal",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "version" : 1,
+    "author" : "xcode"
+  }
+}
\ No newline at end of file
diff --git a/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image4.imageset/image4.jpg b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image4.imageset/image4.jpg
new file mode 100644
index 0000000..2c0afe7
--- /dev/null
+++ b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image4.imageset/image4.jpg
Binary files differ
diff --git a/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image5.imageset/Contents.json b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image5.imageset/Contents.json
new file mode 100644
index 0000000..3586df1
--- /dev/null
+++ b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image5.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+  "images" : [
+    {
+      "idiom" : "universal",
+      "scale" : "1x",
+      "filename" : "image5.jpg"
+    },
+    {
+      "idiom" : "universal",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "universal",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "version" : 1,
+    "author" : "xcode"
+  }
+}
\ No newline at end of file
diff --git a/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image5.imageset/image5.jpg b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image5.imageset/image5.jpg
new file mode 100644
index 0000000..c8a9c0c
--- /dev/null
+++ b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image5.imageset/image5.jpg
Binary files differ
diff --git a/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image6.imageset/Contents.json b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image6.imageset/Contents.json
new file mode 100644
index 0000000..66ed510
--- /dev/null
+++ b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image6.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+  "images" : [
+    {
+      "idiom" : "universal",
+      "scale" : "1x",
+      "filename" : "image6.jpg"
+    },
+    {
+      "idiom" : "universal",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "universal",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "version" : 1,
+    "author" : "xcode"
+  }
+}
\ No newline at end of file
diff --git a/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image6.imageset/image6.jpg b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image6.imageset/image6.jpg
new file mode 100644
index 0000000..5ea30a1
--- /dev/null
+++ b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image6.imageset/image6.jpg
Binary files differ
diff --git a/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image7.imageset/Contents.json b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image7.imageset/Contents.json
new file mode 100644
index 0000000..696608f
--- /dev/null
+++ b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image7.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+  "images" : [
+    {
+      "idiom" : "universal",
+      "scale" : "1x",
+      "filename" : "image7.jpg"
+    },
+    {
+      "idiom" : "universal",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "universal",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "version" : 1,
+    "author" : "xcode"
+  }
+}
\ No newline at end of file
diff --git a/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image7.imageset/image7.jpg b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image7.imageset/image7.jpg
new file mode 100644
index 0000000..791f08a
--- /dev/null
+++ b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image7.imageset/image7.jpg
Binary files differ
diff --git a/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image8.imageset/Contents.json b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image8.imageset/Contents.json
new file mode 100644
index 0000000..61bb310
--- /dev/null
+++ b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image8.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+  "images" : [
+    {
+      "idiom" : "universal",
+      "scale" : "1x",
+      "filename" : "image8.jpg"
+    },
+    {
+      "idiom" : "universal",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "universal",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "version" : 1,
+    "author" : "xcode"
+  }
+}
\ No newline at end of file
diff --git a/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image8.imageset/image8.jpg b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image8.imageset/image8.jpg
new file mode 100644
index 0000000..dcb270d
--- /dev/null
+++ b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image8.imageset/image8.jpg
Binary files differ
diff --git a/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image9.imageset/Contents.json b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image9.imageset/Contents.json
new file mode 100644
index 0000000..6e8ec1f
--- /dev/null
+++ b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image9.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+  "images" : [
+    {
+      "idiom" : "universal",
+      "scale" : "1x",
+      "filename" : "image9.jpg"
+    },
+    {
+      "idiom" : "universal",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "universal",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "version" : 1,
+    "author" : "xcode"
+  }
+}
\ No newline at end of file
diff --git a/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image9.imageset/image9.jpg b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image9.imageset/image9.jpg
new file mode 100644
index 0000000..8f2c787
--- /dev/null
+++ b/examples/apps/Catalog/Catalog/PhotoAlbum.xcassets/image9.imageset/image9.jpg
Binary files differ
diff --git a/examples/apps/Catalog/TableOfContents.swift b/examples/apps/Catalog/TableOfContents.swift
index 6061241..e805ded 100644
--- a/examples/apps/Catalog/TableOfContents.swift
+++ b/examples/apps/Catalog/TableOfContents.swift
@@ -16,6 +16,10 @@
 
 // MARK: Catalog by convention
 
+extension ContextualExampleViewController {
+  class func catalogBreadcrumbs() -> [String] { return ["Contextual transition"] }
+}
+
 extension FadeExampleViewController {
   class func catalogBreadcrumbs() -> [String] { return ["Fade transition"] }
 }
@@ -28,6 +32,10 @@
   class func catalogBreadcrumbs() -> [String] { return ["Menu transition"] }
 }
 
+extension PhotoAlbumExampleViewController {
+  class func catalogBreadcrumbs() -> [String] { return ["Photo album transition"] }
+}
+
 extension CustomPresentationExampleViewController {
   class func catalogBreadcrumbs() -> [String] { return ["Custom presentation transitions"] }
 }
diff --git a/examples/apps/Catalog/TransitionsCatalog.xcodeproj/project.pbxproj b/examples/apps/Catalog/TransitionsCatalog.xcodeproj/project.pbxproj
index d052675..1cb0d9a 100644
--- a/examples/apps/Catalog/TransitionsCatalog.xcodeproj/project.pbxproj
+++ b/examples/apps/Catalog/TransitionsCatalog.xcodeproj/project.pbxproj
@@ -18,6 +18,10 @@
 		667A3F491DEE269400CB3A99 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 667A3F481DEE269400CB3A99 /* Assets.xcassets */; };
 		667A3F4C1DEE269400CB3A99 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 667A3F4A1DEE269400CB3A99 /* LaunchScreen.storyboard */; };
 		667A3F541DEE273000CB3A99 /* TableOfContents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 667A3F531DEE273000CB3A99 /* TableOfContents.swift */; };
+		668E28851F4F5389008A4550 /* FadeTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 668E28841F4F5389008A4550 /* FadeTransition.swift */; };
+		668E288B1F4F68D2008A4550 /* ContextualExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 668E288A1F4F68D2008A4550 /* ContextualExample.swift */; };
+		668E288E1F5066AA008A4550 /* PhotoAlbumExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 668E288D1F5066AA008A4550 /* PhotoAlbumExample.swift */; };
+		668E28901F50673A008A4550 /* PhotoAlbum.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 668E288F1F50673A008A4550 /* PhotoAlbum.xcassets */; };
 		66A320FC1F1E716600E2EAC3 /* NavControllerFadeExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 664CC3D91F1E6F3000B80804 /* NavControllerFadeExample.swift */; };
 		66BBC75E1ED37DAD0015CB9B /* FadeExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66BBC75D1ED37DAD0015CB9B /* FadeExample.swift */; };
 		66BBC76D1ED4C8790015CB9B /* ExampleViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66BBC7691ED4C8790015CB9B /* ExampleViewController.swift */; };
@@ -57,7 +61,7 @@
 		6629151F1ED5E137002B9A5D /* ModalViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ModalViewController.swift; sourceTree = "<group>"; };
 		662915211ED5F222002B9A5D /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; name = README.md; path = ../../../README.md; sourceTree = "<group>"; };
 		662915221ED64A10002B9A5D /* TransitionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransitionTests.swift; sourceTree = "<group>"; };
-		664CC3D91F1E6F3000B80804 /* NavControllerFadeExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavControllerFadeExample.swift; sourceTree = "<group>"; };
+		664CC3D91F1E6F3000B80804 /* NavControllerFadeExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = NavControllerFadeExample.swift; path = ../NavControllerFadeExample.swift; sourceTree = "<group>"; };
 		666FAA801D384A6B000363DA /* TransitionsCatalog.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TransitionsCatalog.app; sourceTree = BUILT_PRODUCTS_DIR; };
 		666FAA831D384A6B000363DA /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = Catalog/AppDelegate.swift; sourceTree = "<group>"; };
 		666FAA8A1D384A6B000363DA /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@@ -71,14 +75,18 @@
 		667A3F4B1DEE269400CB3A99 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
 		667A3F4D1DEE269400CB3A99 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
 		667A3F531DEE273000CB3A99 /* TableOfContents.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TableOfContents.swift; sourceTree = "<group>"; };
-		66BBC75D1ED37DAD0015CB9B /* FadeExample.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FadeExample.swift; sourceTree = "<group>"; };
+		668E28841F4F5389008A4550 /* FadeTransition.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FadeTransition.swift; sourceTree = "<group>"; };
+		668E288A1F4F68D2008A4550 /* ContextualExample.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContextualExample.swift; sourceTree = "<group>"; };
+		668E288D1F5066AA008A4550 /* PhotoAlbumExample.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoAlbumExample.swift; sourceTree = "<group>"; };
+		668E288F1F50673A008A4550 /* PhotoAlbum.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = PhotoAlbum.xcassets; sourceTree = "<group>"; };
+		66BBC75D1ED37DAD0015CB9B /* FadeExample.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = FadeExample.swift; path = ../FadeExample.swift; sourceTree = "<group>"; };
 		66BBC7691ED4C8790015CB9B /* ExampleViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExampleViewController.swift; sourceTree = "<group>"; };
 		66BBC76A1ED4C8790015CB9B /* ExampleViews.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExampleViews.swift; sourceTree = "<group>"; };
 		66BBC76B1ED4C8790015CB9B /* HexColor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HexColor.swift; sourceTree = "<group>"; };
 		66BBC76C1ED4C8790015CB9B /* Layout.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Layout.swift; sourceTree = "<group>"; };
 		66BBC7711ED728DB0015CB9B /* TransitionWithPresentationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransitionWithPresentationTests.swift; sourceTree = "<group>"; };
-		66BBC7731ED729A70015CB9B /* FadeExample.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FadeExample.h; sourceTree = "<group>"; };
-		66BBC7741ED729A70015CB9B /* FadeExample.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FadeExample.m; sourceTree = "<group>"; };
+		66BBC7731ED729A70015CB9B /* FadeExample.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = FadeExample.h; path = ../FadeExample.h; sourceTree = "<group>"; };
+		66BBC7741ED729A70015CB9B /* FadeExample.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = FadeExample.m; path = ../FadeExample.m; sourceTree = "<group>"; };
 		738D98979677D88D24513391 /* Pods-TransitionsCatalog.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TransitionsCatalog.debug.xcconfig"; path = "../../../Pods/Target Support Files/Pods-TransitionsCatalog/Pods-TransitionsCatalog.debug.xcconfig"; sourceTree = "<group>"; };
 		D7BB2931AFCEE4C91AE92E5D /* Pods-UnitTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-UnitTests.release.xcconfig"; path = "../../../Pods/Target Support Files/Pods-UnitTests/Pods-UnitTests.release.xcconfig"; sourceTree = "<group>"; };
 /* End PBXFileReference section */
@@ -169,12 +177,11 @@
 		666FAAA31D384B13000363DA /* examples */ = {
 			isa = PBXGroup;
 			children = (
-				66BBC75D1ED37DAD0015CB9B /* FadeExample.swift */,
-				6629151D1ED5E0E0002B9A5D /* CustomPresentationExample.swift */,
-				66BBC7731ED729A70015CB9B /* FadeExample.h */,
-				66BBC7741ED729A70015CB9B /* FadeExample.m */,
+				668E28891F4F68C3008A4550 /* Contextual transition */,
+				668E28861F4F66C7008A4550 /* Custom presentation */,
+				668E28831F4F5371008A4550 /* Fade transition */,
+				668E288C1F506698008A4550 /* Photo album */,
 				072A063A1EEE26A900B9B5FC /* MenuExample.swift */,
-				664CC3D91F1E6F3000B80804 /* NavControllerFadeExample.swift */,
 			);
 			name = examples;
 			path = ../..;
@@ -192,6 +199,7 @@
 			isa = PBXGroup;
 			children = (
 				666FAA8A1D384A6B000363DA /* Assets.xcassets */,
+				668E288F1F50673A008A4550 /* PhotoAlbum.xcassets */,
 				666FAA8C1D384A6B000363DA /* LaunchScreen.storyboard */,
 				666FAA8F1D384A6B000363DA /* Info.plist */,
 			);
@@ -218,6 +226,43 @@
 			path = ../TestHarness;
 			sourceTree = "<group>";
 		};
+		668E28831F4F5371008A4550 /* Fade transition */ = {
+			isa = PBXGroup;
+			children = (
+				66BBC75D1ED37DAD0015CB9B /* FadeExample.swift */,
+				66BBC7731ED729A70015CB9B /* FadeExample.h */,
+				66BBC7741ED729A70015CB9B /* FadeExample.m */,
+				664CC3D91F1E6F3000B80804 /* NavControllerFadeExample.swift */,
+				668E28841F4F5389008A4550 /* FadeTransition.swift */,
+			);
+			name = "Fade transition";
+			path = transitions;
+			sourceTree = "<group>";
+		};
+		668E28861F4F66C7008A4550 /* Custom presentation */ = {
+			isa = PBXGroup;
+			children = (
+				6629151D1ED5E0E0002B9A5D /* CustomPresentationExample.swift */,
+			);
+			name = "Custom presentation";
+			sourceTree = "<group>";
+		};
+		668E28891F4F68C3008A4550 /* Contextual transition */ = {
+			isa = PBXGroup;
+			children = (
+				668E288A1F4F68D2008A4550 /* ContextualExample.swift */,
+			);
+			name = "Contextual transition";
+			sourceTree = "<group>";
+		};
+		668E288C1F506698008A4550 /* Photo album */ = {
+			isa = PBXGroup;
+			children = (
+				668E288D1F5066AA008A4550 /* PhotoAlbumExample.swift */,
+			);
+			name = "Photo album";
+			sourceTree = "<group>";
+		};
 		66BBC7681ED4C8790015CB9B /* supplemental */ = {
 			isa = PBXGroup;
 			children = (
@@ -354,6 +399,7 @@
 			isa = PBXResourcesBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
+				668E28901F50673A008A4550 /* PhotoAlbum.xcassets in Resources */,
 				666FAA8E1D384A6B000363DA /* LaunchScreen.storyboard in Resources */,
 				666FAA8B1D384A6B000363DA /* Assets.xcassets in Resources */,
 			);
@@ -482,10 +528,13 @@
 				66BBC76D1ED4C8790015CB9B /* ExampleViewController.swift in Sources */,
 				667A3F541DEE273000CB3A99 /* TableOfContents.swift in Sources */,
 				66A320FC1F1E716600E2EAC3 /* NavControllerFadeExample.swift in Sources */,
+				668E288E1F5066AA008A4550 /* PhotoAlbumExample.swift in Sources */,
 				66BBC7701ED4C8790015CB9B /* Layout.swift in Sources */,
 				6629151E1ED5E0E0002B9A5D /* CustomPresentationExample.swift in Sources */,
+				668E288B1F4F68D2008A4550 /* ContextualExample.swift in Sources */,
 				66BBC76E1ED4C8790015CB9B /* ExampleViews.swift in Sources */,
 				662915201ED5E137002B9A5D /* ModalViewController.swift in Sources */,
+				668E28851F4F5389008A4550 /* FadeTransition.swift in Sources */,
 				66BBC75E1ED37DAD0015CB9B /* FadeExample.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
diff --git a/examples/transitions/FadeTransition.swift b/examples/transitions/FadeTransition.swift
new file mode 100644
index 0000000..620d279
--- /dev/null
+++ b/examples/transitions/FadeTransition.swift
@@ -0,0 +1,56 @@
+/*
+ Copyright 2017-present The Material Motion Authors. All Rights Reserved.
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+
+import UIKit
+import MotionTransitioning
+
+// Transitions must be NSObject types that conform to the Transition protocol.
+final class FadeTransition: NSObject, Transition {
+
+  // The sole method we're expected to implement, start is invoked each time the view controller is
+  // presented or dismissed.
+  func start(with context: TransitionContext) {
+    CATransaction.begin()
+
+    CATransaction.setCompletionBlock {
+      // Let UIKit know that the transition has come to an end.
+      context.transitionDidEnd()
+    }
+
+    let fade = CABasicAnimation(keyPath: "opacity")
+
+    fade.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
+
+    // Define our animation assuming that we're going forward (presenting)...
+    fade.fromValue = 0
+    fade.toValue = 1
+
+    // ...and reverse it if we're going backwards (dismissing).
+    if context.direction == .backward {
+      let swap = fade.fromValue
+      fade.fromValue = fade.toValue
+      fade.toValue = swap
+    }
+
+    // Add the animation...
+    context.foreViewController.view.layer.add(fade, forKey: fade.keyPath)
+
+    // ...and ensure that our model layer reflects the final value.
+    context.foreViewController.view.layer.setValue(fade.toValue, forKeyPath: fade.keyPath!)
+
+    CATransaction.commit()
+  }
+}
diff --git a/src/MDMTransition.h b/src/MDMTransition.h
index de8eda7..a1ed123 100644
--- a/src/MDMTransition.h
+++ b/src/MDMTransition.h
@@ -41,10 +41,12 @@
  */
 NS_SWIFT_NAME(TransitionWithCustomDuration)
 @protocol MDMTransitionWithCustomDuration
+
 /**
  The desired duration of this transition in seconds.
  */
 - (NSTimeInterval)transitionDurationWithContext:(nonnull id<MDMTransitionContext>)context;
+
 @end
 
 /**
diff --git a/src/MDMTransitionContext.h b/src/MDMTransitionContext.h
index 38123e1..6351d51 100644
--- a/src/MDMTransitionContext.h
+++ b/src/MDMTransitionContext.h
@@ -16,6 +16,8 @@
 
 #import <UIKit/UIKit.h>
 
+@protocol MDMTransitionViewSnapshotting;
+
 /**
  The possible directions of a transition.
  */
@@ -83,4 +85,12 @@
  The presentation view controller for this transition.
  */
 @property(nonatomic, strong, readonly, nullable) UIPresentationController *presentationController;
+
+/**
+ Defers execution of the provided work until the completion of the transition.
+
+ Upon completion, each block of work will be executed in the order it was provided to the context.
+ */
+- (void)deferToCompletion:(void (^ _Nonnull)())work;
+
 @end
diff --git a/src/MDMTransitionNavigationControllerDelegate.m b/src/MDMTransitionNavigationControllerDelegate.m
index 55996fc..da377bb 100644
--- a/src/MDMTransitionNavigationControllerDelegate.m
+++ b/src/MDMTransitionNavigationControllerDelegate.m
@@ -17,7 +17,7 @@
 #import "MDMTransitionNavigationControllerDelegate.h"
 
 #import "MDMTransitionContext.h"
-#import "private/MDMPresentationTransitionController.h"
+#import "private/MDMViewControllerTransitionController.h"
 #import "private/MDMViewControllerTransitionContext.h"
 
 @interface MDMTransitionNavigationControllerDelegate () <UINavigationControllerDelegate>
diff --git a/src/MDMTransitionPresentationController.h b/src/MDMTransitionPresentationController.h
new file mode 100644
index 0000000..1def830
--- /dev/null
+++ b/src/MDMTransitionPresentationController.h
@@ -0,0 +1,91 @@
+/*
+ Copyright 2017-present The Material Motion Authors. All Rights Reserved.
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+
+#import <Foundation/Foundation.h>
+#import <UIKit/UIKit.h>
+
+@protocol MDMTransitionContext;
+@protocol MDMTransitionPresentationAnimationControlling;
+
+NS_SWIFT_NAME(TransitionFrameCalculation)
+typedef CGRect (^MDMTransitionFrameCalculation)(UIPresentationController * _Nonnull);
+
+/**
+ A transition presentation controller implementation that supports animation delegation, a darkened
+ overlay view, and custom presentation frames.
+ 
+ The presentation controller will create and manage the lifecycle of the scrim view, ensuring that
+ it is removed upon a completed dismissal of the presented view controller.
+ */
+NS_SWIFT_NAME(TransitionPresentationController)
+@interface MDMTransitionPresentationController : UIPresentationController
+
+/**
+ Initializes a presentation controller with the standard values and a frame calculation block.
+ 
+ The frame calculation block is expected to return the desired frame of the presented view
+ controller.
+ */
+- (nonnull instancetype)initWithPresentedViewController:(nonnull UIViewController *)presentedViewController
+                               presentingViewController:(nonnull UIViewController *)presentingViewController
+                          calculateFrameOfPresentedView:(nullable MDMTransitionFrameCalculation)calculateFrameOfPresentedView
+NS_DESIGNATED_INITIALIZER;
+
+/**
+ The presentation controller's scrim view.
+ */
+@property(nonatomic, strong, nullable, readonly) UIView * scrimView;
+
+/**
+ The animation controller is able to customize animations in reaction to view controller
+ presentation and dismissal events.
+
+ The animation controller is explicitly nil'd upon completion of the dismissal transition.
+ */
+@property(nonatomic, strong, nullable) id <MDMTransitionPresentationAnimationControlling> animationController;
+
+@end
+
+/**
+ An animation controller receives additional presentation- and dismissal-related events during a
+ view controller transition.
+ */
+NS_SWIFT_NAME(TransitionPresentationAnimationControlling)
+@protocol MDMTransitionPresentationAnimationControlling <NSObject>
+@optional
+
+/**
+ Allows the receiver to register animations for the given transition context.
+
+ Invoked prior to the Transition instance's startWithContext.
+ 
+ If not implemented, the scrim view will be faded in during presentation and out during dismissal.
+ */
+- (void)presentationController:(nonnull MDMTransitionPresentationController *)presentationController
+              startWithContext:(nonnull NSObject<MDMTransitionContext> *)context;
+
+/**
+ Informs the receiver that the dismissal transition is about to begin.
+ */
+- (void)dismissalTransitionWillBeginWithPresentationController:(nonnull MDMTransitionPresentationController *)presentationController;
+
+/**
+ Informs the receiver that the dismissal transition has completed.
+ */
+- (void)presentationController:(nonnull MDMTransitionPresentationController *)presentationController
+     dismissalTransitionDidEnd:(BOOL)completed;
+
+@end
diff --git a/src/MDMTransitionPresentationController.m b/src/MDMTransitionPresentationController.m
new file mode 100644
index 0000000..684875a
--- /dev/null
+++ b/src/MDMTransitionPresentationController.m
@@ -0,0 +1,120 @@
+/*
+ Copyright 2017-present The Material Motion Authors. All Rights Reserved.
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+
+#import "MDMTransitionPresentationController.h"
+
+#import "MDMTransition.h"
+#import "MDMTransitionContext.h"
+#import "MDMTransitionController.h"
+#import "UIViewController+TransitionController.h"
+
+@interface MDMTransitionPresentationController () <MDMTransition>
+@end
+
+@implementation MDMTransitionPresentationController {
+  CGRect (^_calculateFrameOfPresentedView)(UIPresentationController *);
+}
+
+- (instancetype)initWithPresentedViewController:(UIViewController *)presentedViewController
+                       presentingViewController:(UIViewController *)presentingViewController
+                  calculateFrameOfPresentedView:(MDMTransitionFrameCalculation)calculateFrameOfPresentedView {
+  self = [super initWithPresentedViewController:presentedViewController
+                       presentingViewController:presentingViewController];
+  if (self) {
+    _calculateFrameOfPresentedView = [calculateFrameOfPresentedView copy];
+  }
+  return self;
+}
+
+- (instancetype)initWithPresentedViewController:(UIViewController *)presentedViewController presentingViewController:(UIViewController *)presentingViewController {
+  return [self initWithPresentedViewController:presentedViewController
+                      presentingViewController:presentingViewController
+                 calculateFrameOfPresentedView:nil];
+}
+
+- (CGRect)frameOfPresentedViewInContainerView {
+  if (_calculateFrameOfPresentedView) {
+    return _calculateFrameOfPresentedView(self);
+  } else {
+    return self.containerView.bounds;
+  }
+}
+
+- (BOOL)shouldRemovePresentersView {
+  // We don't have access to the container view when this method is called, so we can only guess as
+  // to whether we'll be presenting full screen by checking for the presence of a frame calculation
+  // block.
+  BOOL definitelyFullscreen = _calculateFrameOfPresentedView == nil;
+
+  // Returning true here will cause UIKit to invoke viewWillDisappear and viewDidDisappear on the
+  // presenting view controller, and the presenting view controller's view will be removed on
+  // completion of the transition.
+  return definitelyFullscreen;
+}
+
+- (void)dismissalTransitionWillBegin {
+  if (!self.presentedViewController.mdm_transitionController.activeTransition) {
+    [self.presentedViewController.transitionCoordinator animateAlongsideTransition:^(id<UIViewControllerTransitionCoordinatorContext>  _Nonnull context) {
+      self.scrimView.alpha = 0;
+    } completion:nil];
+
+    if ([self.animationController respondsToSelector:@selector(dismissalTransitionWillBeginWithPresentationController:)]) {
+      [self.animationController dismissalTransitionWillBeginWithPresentationController:self];
+    }
+  }
+}
+
+- (void)dismissalTransitionDidEnd:(BOOL)completed {
+  if (completed) {
+    [self.scrimView removeFromSuperview];
+    _scrimView = nil;
+
+  } else {
+    self.scrimView.alpha = 1;
+  }
+
+  if ([self.animationController respondsToSelector:@selector(presentationController:dismissalTransitionDidEnd:)]) {
+    [self.animationController presentationController:self dismissalTransitionDidEnd:completed];
+  }
+
+  if (completed) {
+    // Break any potential memory cycles due to our strong ownership of the animation controller.
+    self.animationController = nil;
+  }
+}
+
+- (void)startWithContext:(NSObject<MDMTransitionContext> *)context {
+  if (!self.scrimView) {
+    _scrimView = [[UIView alloc] initWithFrame:context.containerView.bounds];
+    self.scrimView.autoresizingMask = (UIViewAutoresizingFlexibleWidth
+                                       | UIViewAutoresizingFlexibleHeight);
+    self.scrimView.backgroundColor = [UIColor colorWithWhite:0 alpha:0.3f];
+    [context.containerView insertSubview:self.scrimView
+                            belowSubview:context.foreViewController.view];
+  }
+
+  if ([self.animationController respondsToSelector:@selector(presentationController:startWithContext:)]) {
+    [self.animationController presentationController:self startWithContext:context];
+  } else {
+    self.scrimView.alpha = context.direction == MDMTransitionDirectionForward ? 0 : 1;
+
+    [UIView animateWithDuration:context.duration animations:^{
+      self.scrimView.alpha = context.direction == MDMTransitionDirectionForward ? 1 : 0;
+    }];
+  }
+}
+
+@end
diff --git a/src/MDMTransitionViewSnapshotter.h b/src/MDMTransitionViewSnapshotter.h
new file mode 100644
index 0000000..71c6ed3
--- /dev/null
+++ b/src/MDMTransitionViewSnapshotter.h
@@ -0,0 +1,57 @@
+/*
+ Copyright 2017-present The Material Motion Authors. All Rights Reserved.
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+
+#import <Foundation/Foundation.h>
+#import <UIKit/UIKit.h>
+
+/**
+ A view snapshotter creates visual replicas of views so that they may be animated during a
+ transition without adversely affecting the original view hierarchy.
+ */
+NS_SWIFT_NAME(TransitionViewSnapshotter)
+@interface MDMTransitionViewSnapshotter : NSObject
+
+/**
+ Initializes a snapshotter with a given container view.
+
+ All snapshot views will be added to the container view as a direct subview.
+ */
+- (nonnull instancetype)initWithContainerView:(nonnull UIView *)containerView NS_DESIGNATED_INITIALIZER;
+
+/**
+ Returns a snapshot view of the provided view.
+
+ The snapshotter will keep a reference to the returned view in order to facilitate its eventual
+ removal via removeAllSnapshots once the snapshot is no longer needed.
+
+ @param view The view to be snapshotted.
+ @param isAppearing If the view is appearing for the first time, a slower form of snapshotting may
+ be used. Otherwise, fast snapshotting may be used.
+ @return A new UIView instance that can be used as a visual replica of the provided view.
+ */
+- (nonnull UIView *)snapshotOfView:(nonnull UIView *)view isAppearing:(BOOL)isAppearing;
+
+/**
+ Removes all snapshots from their superview and unhide the snapshotted views.
+ */
+- (void)removeAllSnapshots;
+
+/**
+ Unavailable. Use initWithContainerView: instead.
+ */
+- (nonnull instancetype)init NS_UNAVAILABLE;
+
+@end
diff --git a/src/MDMTransitionViewSnapshotter.m b/src/MDMTransitionViewSnapshotter.m
new file mode 100644
index 0000000..7978c4d
--- /dev/null
+++ b/src/MDMTransitionViewSnapshotter.m
@@ -0,0 +1,132 @@
+/*
+ Copyright 2017-present The Material Motion Authors. All Rights Reserved.
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+
+#import "MDMTransitionViewSnapshotter.h"
+
+static UIView *FastSnapshotOfView(UIView *view) {
+  return [view snapshotViewAfterScreenUpdates:NO];
+}
+
+static UIView *SlowSnapshotOfView(UIView *view) {
+  UIGraphicsBeginImageContextWithOptions(view.bounds.size, NO, 0);
+  [view.layer renderInContext:UIGraphicsGetCurrentContext()];
+  UIImage *copied = UIGraphicsGetImageFromCurrentImageContext();
+  UIView *copiedView = [[UIImageView alloc] initWithImage:copied];
+  UIGraphicsEndImageContext();
+  return copiedView;
+}
+
+@implementation MDMTransitionViewSnapshotter {
+  UIView *_containerView;
+  NSMutableArray *_snapshotViews;
+  NSMutableArray *_hiddenViews;
+}
+
+- (void)dealloc {
+  for (UIView *view in _snapshotViews) {
+    [view removeFromSuperview];
+  }
+  for (UIView *view in _hiddenViews) {
+    view.hidden = NO;
+  }
+}
+
+- (instancetype)initWithContainerView:(UIView *)containerView {
+  self = [super init];
+  if (self) {
+    _containerView = containerView;
+
+    _snapshotViews = [NSMutableArray array];
+    _hiddenViews = [NSMutableArray array];
+  }
+  return self;
+}
+
+- (UIView *)snapshotOfView:(UIView *)view isAppearing:(BOOL)isAppearing {
+  UIView *snapshotView;
+  if ([view isKindOfClass:[UIImageView class]]) {
+    snapshotView = [self richReplicaOfImageView:(UIImageView *)view];
+
+  } else {
+    snapshotView = isAppearing ? SlowSnapshotOfView(view) : FastSnapshotOfView(view);
+  }
+
+  snapshotView.layer.borderColor = view.layer.borderColor;
+  snapshotView.layer.borderWidth = view.layer.borderWidth;
+  snapshotView.layer.cornerRadius = view.layer.cornerRadius;
+  snapshotView.layer.shadowColor = view.layer.shadowColor;
+  snapshotView.layer.shadowOffset = view.layer.shadowOffset;
+  snapshotView.layer.shadowOpacity = view.layer.shadowOpacity;
+  snapshotView.layer.shadowPath = view.layer.shadowPath;
+  snapshotView.layer.shadowRadius = view.layer.shadowRadius;
+
+  snapshotView.layer.position = [_containerView convertPoint:view.layer.position fromView:view.superview];
+  snapshotView.layer.bounds = view.layer.bounds;
+  snapshotView.layer.transform = view.layer.transform;
+
+  [_containerView addSubview:snapshotView];
+  [_snapshotViews addObject:snapshotView];
+
+  [_hiddenViews addObject:view];
+  view.hidden = YES;
+
+  return snapshotView;
+}
+
+- (void)removeAllSnapshots {
+  for (UIView *view in _snapshotViews) {
+    [view removeFromSuperview];
+  }
+  for (UIView *view in _hiddenViews) {
+    view.hidden = NO;
+  }
+
+  [_snapshotViews removeAllObjects];
+  [_hiddenViews removeAllObjects];
+}
+
+#pragma mark - Private
+
+- (UIView *)richReplicaOfImageView:(UIImageView *)imageView {
+  UIImageView *copiedImageView = [[UIImageView alloc] init];
+
+  copiedImageView.image = imageView.image;
+  copiedImageView.highlightedImage = imageView.highlightedImage;
+
+  copiedImageView.animationImages = imageView.animationImages;
+  copiedImageView.highlightedAnimationImages = imageView.highlightedAnimationImages;
+  copiedImageView.animationDuration = imageView.animationDuration;
+  copiedImageView.animationRepeatCount = imageView.animationRepeatCount;
+
+  [self copyPropertiesFrom:imageView toView:copiedImageView];
+
+  return copiedImageView;
+}
+
+- (void)copyPropertiesFrom:(UIView *)view toView:(UIView *)copiedView {
+  copiedView.clipsToBounds = view.clipsToBounds;
+  copiedView.backgroundColor = view.backgroundColor;
+  copiedView.alpha = view.alpha;
+  copiedView.opaque = view.isOpaque;
+  copiedView.clearsContextBeforeDrawing = view.clearsContextBeforeDrawing;
+  copiedView.hidden = view.isHidden;
+  copiedView.contentMode = view.contentMode;
+  copiedView.maskView = view.maskView;
+  copiedView.tintColor = view.tintColor;
+  copiedView.userInteractionEnabled = view.isUserInteractionEnabled;
+}
+
+@end
diff --git a/src/MotionTransitioning.h b/src/MotionTransitioning.h
index c2caf73..b03ef97 100644
--- a/src/MotionTransitioning.h
+++ b/src/MotionTransitioning.h
@@ -18,4 +18,6 @@
 #import "MDMTransitionContext.h"
 #import "MDMTransitionController.h"
 #import "MDMTransitionNavigationControllerDelegate.h"
+#import "MDMTransitionPresentationController.h"
+#import "MDMTransitionViewSnapshotter.h"
 #import "UIViewController+TransitionController.h"
diff --git a/src/UIViewController+TransitionController.m b/src/UIViewController+TransitionController.m
index 5f9de4a..cae45cc 100644
--- a/src/UIViewController+TransitionController.m
+++ b/src/UIViewController+TransitionController.m
@@ -16,7 +16,7 @@
 
 #import "UIViewController+TransitionController.h"
 
-#import "private/MDMPresentationTransitionController.h"
+#import "private/MDMViewControllerTransitionController.h"
 
 #import <objc/runtime.h>
 
@@ -27,9 +27,9 @@
 - (id<MDMTransitionController>)mdm_transitionController {
   const void *key = [self mdm_transitionControllerKey];
 
-  MDMPresentationTransitionController *controller = objc_getAssociatedObject(self, key);
+  MDMViewControllerTransitionController *controller = objc_getAssociatedObject(self, key);
   if (!controller) {
-    controller = [[MDMPresentationTransitionController alloc] initWithViewController:self];
+    controller = [[MDMViewControllerTransitionController alloc] initWithViewController:self];
     [self mdm_setTransitionController:controller];
   }
   return controller;
@@ -37,11 +37,11 @@
 
 #pragma mark - Private
 
-- (void)mdm_setTransitionController:(MDMPresentationTransitionController *)controller {
+- (void)mdm_setTransitionController:(MDMViewControllerTransitionController *)controller {
   const void *key = [self mdm_transitionControllerKey];
 
   // Clear the previous delegate if we'd previously set one.
-  MDMPresentationTransitionController *existingController = objc_getAssociatedObject(self, key);
+  MDMViewControllerTransitionController *existingController = objc_getAssociatedObject(self, key);
   id<UIViewControllerTransitioningDelegate> delegate = self.transitioningDelegate;
   if (existingController == delegate) {
     self.transitioningDelegate = nil;
diff --git a/src/private/MDMViewControllerTransitionContext.m b/src/private/MDMViewControllerTransitionContext.m
index ed5d1a0..acfd5db 100644
--- a/src/private/MDMViewControllerTransitionContext.m
+++ b/src/private/MDMViewControllerTransitionContext.m
@@ -20,6 +20,7 @@
 
 @implementation MDMViewControllerTransitionContext {
   id<UIViewControllerContextTransitioning> _transitionContext;
+  NSMutableArray *_completionBlocks;
 }
 
 @synthesize direction = _direction;
@@ -43,6 +44,8 @@
     _foreViewController = foreViewController;
     _presentationController = presentationController;
 
+    _completionBlocks = [NSMutableArray array];
+
     _transition = [self fallbackForTransition:_transition];
     if (!_transition) {
       return nil;
@@ -69,7 +72,7 @@
 
 // TODO(featherless): Implement interactive transitioning. Need to implement
 // UIViewControllerInteractiveTransitioning here and isInteractive and interactionController* in
-// MDMPresentationTransitionController.
+// MDMViewControllerTransitionController.
 
 #pragma mark - MDMTransitionContext
 
@@ -85,10 +88,18 @@
   [_transitionContext completeTransition:true];
 
   _transition = nil;
+  for (void (^work)() in _completionBlocks) {
+    work();
+  }
+  [_completionBlocks removeAllObjects];
 
   [_delegate transitionDidCompleteWithContext:self];
 }
 
+- (void)deferToCompletion:(void (^)())work {
+  [_completionBlocks addObject:[work copy]];
+}
+
 #pragma mark - Private
 
 - (void)initiateTransition {
diff --git a/src/private/MDMPresentationTransitionController.h b/src/private/MDMViewControllerTransitionController.h
similarity index 87%
rename from src/private/MDMPresentationTransitionController.h
rename to src/private/MDMViewControllerTransitionController.h
index 22fe219..ed046d5 100644
--- a/src/private/MDMPresentationTransitionController.h
+++ b/src/private/MDMViewControllerTransitionController.h
@@ -19,7 +19,7 @@
 
 #import "MDMTransitionController.h"
 
-@interface MDMPresentationTransitionController : NSObject <MDMTransitionController, UIViewControllerTransitioningDelegate>
+@interface MDMViewControllerTransitionController : NSObject <MDMTransitionController, UIViewControllerTransitioningDelegate>
 
 - (nonnull instancetype)initWithViewController:(nonnull UIViewController *)viewController
     NS_DESIGNATED_INITIALIZER;
diff --git a/src/private/MDMPresentationTransitionController.m b/src/private/MDMViewControllerTransitionController.m
similarity index 85%
rename from src/private/MDMPresentationTransitionController.m
rename to src/private/MDMViewControllerTransitionController.m
index a34ef26..6b5efb8 100644
--- a/src/private/MDMPresentationTransitionController.m
+++ b/src/private/MDMViewControllerTransitionController.m
@@ -14,20 +14,20 @@
  limitations under the License.
  */
 
-#import "MDMPresentationTransitionController.h"
+#import "MDMViewControllerTransitionController.h"
 
 #import "MDMTransition.h"
 #import "MDMViewControllerTransitionContext.h"
 
-@interface MDMPresentationTransitionController () <UIViewControllerTransitioningDelegate, MDMViewControllerTransitionContextDelegate>
+@interface MDMViewControllerTransitionController () <UIViewControllerTransitioningDelegate, MDMViewControllerTransitionContextDelegate>
 @end
 
-@implementation MDMPresentationTransitionController {
+@implementation MDMViewControllerTransitionController {
   // We expect the view controller to hold a strong reference to its transition controller, so keep
   // a weak reference to the view controller here.
   __weak UIViewController *_associatedViewController;
 
-  UIPresentationController *_presentationController;
+  __weak UIPresentationController *_presentationController;
 
   MDMViewControllerTransitionContext *_context;
   __weak UIViewController *_source;
@@ -93,10 +93,14 @@
     return nil;
   }
   id<MDMTransitionWithPresentation> withPresentation = (id<MDMTransitionWithPresentation>)_transition;
-  _presentationController = [withPresentation presentationControllerForPresentedViewController:presented
-                                                                      presentingViewController:presenting
-                                                                          sourceViewController:source];
-  return _presentationController;
+  UIPresentationController *presentationController =
+      [withPresentation presentationControllerForPresentedViewController:presented
+                                                presentingViewController:presenting
+                                                    sourceViewController:source];
+  // _presentationController is weakly-held, so we have to do this local var dance to keep it
+  // from being nil'd on assignment.
+  _presentationController = presentationController;
+  return presentationController;
 }
 
 #pragma mark - MDMViewControllerTransitionContextDelegate