Skip to content

Instantly share code, notes, and snippets.

@lelandrichardson
Last active January 19, 2017 18:10
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save lelandrichardson/a37baf613fd96ff2b52711dc78094cc2 to your computer and use it in GitHub Desktop.
Save lelandrichardson/a37baf613fd96ff2b52711dc78094cc2 to your computer and use it in GitHub Desktop.
Shared Element Transitions

Top Level

The core idea behind the shared element transition API is that any screen can define some number of views that are SharedElementGroups and each SharedElementGroup can have some number of views that are SharedElements inside of it.

Each SharedElementGroup has an identifier (a string) which uniquely identifies it on the screen.

Each SharedElement has an identifier (a string) which uniquely identifies it inside of the SharedElementGroup

Also, SharedElements need not live inside of a SharedElementGroup, but then they can only be used in the to portion of the transition, and not the from part.

If a transitionGroup is specified as an option in the navigation call (ie, Navigator.push), then a shared element transition will be attempted with the provided id of the SharedElementGroup on the screen that you are navigating away from.

The Animation

The animation is executed by creating a number of snapshots (using snapshotViewAfterScreenUpdates ) and animating them inside of an animation container using a UIViewControllerTransitioningDelegate

The snapshot itself is several steps, as outlined below. Provided you have the following three inputs:

  1. fromViewController: The view controller you are navigating away from
  2. toViewController: The view controller you are navigating to
  3. transitionGroup: the id of the transition group in the fromViewController that is going to be involved in the transition.

With those parameters, we do the following:

  1. Snapshot fromViewController and put the snapshot on top of the view hierarchy to cover the screen
  2. In fromViewController collect snapshots of every SharedElement inside of the SharedElementGroup with id matching transitionGroup.
  3. Snapshot the entire fromViewController view hierarchy with each SharedElement from #2 having alpha set to 0.
  4. In toViewController collect snapshots of every SharedElement.
  5. Snapshot the entire toViewController view hierarchy with each SharedElement from #4 having alpha set to 0.
  6. Find all of the matching SharedElements in toViewController and fromViewController, matching by typeId etc.
  7. Remove snapshot from #1. Insert snapshots into the animation container in the following order:
  • full screen snapshot of fromViewController minus shared elements
  • all shared elements from fromViewController without a match
  • full screen snapshot of toViewController minus shared elements (set alpha to 0)
  • all shared elements from toViewController without a match (set alpha to 0)
  • all shared elements from fromViewController with matches in toViewController
  1. Execute a UIView.animateWithDuration(...) and set the following things in the animation:
  • full screen snapshot of toViewController minus shared elements (set alpha to 1)
  • all shared elements from toViewController without a match (set alpha to 1)
  • all shared elements from fromViewController with matches in toViewController:
    • set .center to the .center of the corresponding matched element
    • set .transform to be an XY scale transform that matches the ratio of the widths/heights of the element relative to its matched pair.
  1. On completion of animation, remove the animation container completely.
class ScreenOne extends React.Component {
next() {
// When you navigate, you can specify the transitionGroup as an option. If you do, it will do the shared element
// transition. If it's omitted, it will transition normally.
Navigator.push('ScreenTwo', this.props, {
transitionGroup: 'test',
});
}
render() {
return (
<ScrollScene>
<EditorialMarquee
image={this.props.url}
title="Shared Element Transitions"
/>
<RowGroup>
<Row
title="Press Me"
onPress={() => this.next()}
/>
{/* Each page can have 0-many SharedElementGroups. You specify the one you want when navigating. */}
<SharedElementGroup id="test">
{/* Each SharedElementGroup can have 0-many SharedElements. */}
{/* SharedElements have types/ids that "annotate" the element they wrap. */}
<SharedElement
type="test"
typeId={0}
subType="photo"
subTypeId={0}
>
<Image
source={createImage(this.props.url)}
style={{
width: 100,
height: 100,
}}
/>
</SharedElement>
</SharedElementGroup>
<Row title="Filler Row" />
<Row title="Filler Row" />
<Row title="Filler Row" />
</RowGroup>
</ScrollScene>
);
}
}
class ScreenTwo extends React.Component {
render() {
return (
<ScrollScene>
<Marquee
title="Shared Element Transitions"
/>
<RowGroup>
{/*
SharedElements on the page you navigate to are automatically animated if they are found on the
new page with the same type/id.
*/}
<SharedElement
type="test"
typeId={0}
subType="photo"
subTypeId={0}
>
<Image
source={createImage(this.props.url)}
style={{
width: 200,
height: 200,
}}
/>
</SharedElement>
</RowGroup>
</ScrollScene>
);
}
}
import UIKit
struct SharedElementPair {
let from: UIView
let to: UIView
}
public struct ReactAnimationStyle {
let duration: NSTimeInterval
}
public final class ReactSharedElementAnimation: TransitionAnimation {
public typealias Style = ReactAnimationStyle
public typealias FromContent = ReactAnimationFromContent
public typealias ToContent = ReactAnimationToContent
// MARK: Lifecycle
public init(style: Style) {
self.duration = style.duration
}
// MARK: Public
public let duration: NSTimeInterval
private func setToState(
from: UIView,
to: UIView,
toCenter: CGPoint,
ratio: CGSize,
container: UIView
) {
to.center = toCenter
to.transform = CGAffineTransformIdentity
from.transform = CGAffineTransformMakeScale(ratio.width, ratio.height)
from.center = toCenter
}
private func setFromState(
from: UIView,
to: UIView,
fromCenter: CGPoint,
ratio: CGSize,
container: UIView
) {
from.center = fromCenter
from.transform = CGAffineTransformIdentity
to.transform = CGAffineTransformMakeScale(1.0 / ratio.width, 1.0 / ratio.height)
to.center = fromCenter
}
private func animateSharedElementBlockWithContainer(
container: UIView,
isPresenting: Bool,
from: UIView,
to: UIView
) -> (() -> ()) {
let toCenter = to.center
let fromCenter = from.center
let ratio = from.getScaleRatioToView(to)
if isPresenting {
self.setFromState(from, to: to, fromCenter: fromCenter, ratio: ratio, container: container)
return {
self.setToState(from, to: to, toCenter: toCenter, ratio: ratio, container: container)
}
} else {
self.setToState(from, to: to, toCenter: toCenter, ratio: ratio, container: container)
return {
self.setFromState(from, to: to, fromCenter: fromCenter, ratio: ratio, container: container)
}
}
}
private func getSharedElementPairs(fromContent: FromContent, toContent: ToContent) -> [SharedElementPair] {
var pairs = [SharedElementPair]();
for (id, fromView) in fromContent.sharedElements {
if let toView = toContent.sharedElements[id] {
pairs.append(SharedElementPair(from: fromView, to: toView))
}
}
return pairs
}
public func animateWithContainer(
container: UIView,
isPresenting: Bool,
fromContent: FromContent,
toContent: ToContent,
completion: ()->())
{
// we want the "fromContent" to be below the "toContent"
container.sendSubviewToBack(fromContent.screenWithoutElements)
toContent.screenWithoutElements.alpha = isPresenting ? 0 : 1
let sharedElementPairs = getSharedElementPairs(fromContent, toContent: toContent)
let animationBlocks = sharedElementPairs.map({ pair in
return animateSharedElementBlockWithContainer(
container,
isPresenting: isPresenting,
from: pair.from,
to: pair.to
)
})
UIView.animateWithDuration(
duration,
delay: 0,
usingSpringWithDamping: 1,
initialSpringVelocity: 0,
options: [],
animations: {
toContent.screenWithoutElements.alpha = isPresenting ? 1 : 0
animationBlocks.forEach({ fn in fn() })
},
completion: { _ in
completion()
}
)
}
}
public struct ReactAnimationFromContent {
public let screenWithoutElements: UIView
public let sharedElements: [String: UIView]
}
public struct ReactAnimationToContent {
public let screenWithoutElements: UIView
public let sharedElements: [String: UIView]
}
public struct ReactSharedElementSnapshot {
public let screenWithoutElements: UIViewSnapshot
public let sharedElements: [String: UIViewSnapshot] // TODO: why cant this be [String: ViewSnapshot]?
}
protocol ReactAnimationFromContentVendor: class {
func reactAnimationFromContent(animationContainer: UIView, transitionGroup: String) -> ReactAnimationFromContent
func containerView() -> UIView
}
protocol ReactAnimationToContentVendor: class {
func reactAnimationToContent(animationContainer: UIView) -> ReactAnimationToContent
func containerView() -> UIView
}
class ReactSharedElementTransition: NSObject,
UIViewControllerTransitioningDelegate,
UINavigationControllerDelegate,
AIRNavigationControllerDelegate
{
// MARK: Lifecycle
init(
transitionGroup: String,
fromViewController: ReactAnimationFromContentVendor,
toViewController: ReactAnimationToContentVendor,
style: ReactAnimationStyle)
{
self.transitionGroup = transitionGroup
self.fromViewController = fromViewController
self.toViewController = toViewController
self.style = style
super.init()
}
// MARK: Internal
func animationControllerForPresentedController(presented: UIViewController, presentingController presenting: UIViewController, sourceController source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
if !fromViewController.containerView().isDescendantOfView(presenting.view) {
return nil
}
return makeAnimationController(isPresenting: true)
}
func animationControllerForDismissedController(dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
guard let presentingViewController = dismissed.presentingViewController else { return nil }
if !fromViewController.containerView().isDescendantOfView(presentingViewController.view) {
return nil
}
return makeAnimationController(isPresenting: false)
}
func navigationController(
navigationController: UINavigationController,
animationControllerForOperation
operation: UINavigationControllerOperation,
fromViewController fromVC: UIViewController,
toViewController toVC: UIViewController) -> UIViewControllerAnimatedTransitioning?
{
if operation == .Push {
if fromVC === fromViewController && toVC === toViewController {
return makeAnimationController(isPresenting: true)
}
} else if operation == .Pop {
if fromVC === toViewController && toVC === fromViewController {
return makeAnimationController(isPresenting: false)
}
}
return nil
}
// MARK: Private
private let transitionGroup: String
private let fromViewController: ReactAnimationFromContentVendor
private let toViewController: ReactAnimationToContentVendor
private let style: ReactAnimationStyle
private func makeAnimationController(isPresenting isPresenting: Bool) -> UIViewControllerAnimatedTransitioning {
let animationContentGenerator = { [weak self] animationContainer in
return self?.generateAnimationContentFromViewControllers(animationContainer)
}
return AnimatedTransitionController(
isPresenting: isPresenting,
animation: ReactSharedElementAnimation(style: style),
animationContentGenerator: animationContentGenerator)
}
private func generateAnimationContentFromViewControllers(animationContainer: UIView) -> (ReactAnimationFromContent, ReactAnimationToContent) {
let fromContent = self.fromViewController.reactAnimationFromContent(animationContainer, transitionGroup: transitionGroup)
let toContent = self.toViewController.reactAnimationToContent(animationContainer)
return (fromContent, toContent)
}
}
import React
class SharedElementGroup: RCTView {
private var identifier: String?
private var airbnbInstanceId: String?
func setIdentifier(identifier: String!) {
self.identifier = identifier
addToViewControllerIfPossible()
}
func setAirbnbInstanceId(airbnbInstanceId: String!) {
self.airbnbInstanceId = airbnbInstanceId
addToViewControllerIfPossible()
}
func addToViewControllerIfPossible() {
guard let seid = identifier, id = airbnbInstanceId else { return }
let vc = ReactNavigationCoordinator.sharedInstance.viewControllerForId(id)
vc?.sharedElementGroupsById[seid] = WeakViewHolder(view: self)
}
}
@objc(SharedElementGroupManager)
class SharedElementGroupManager: RCTViewManager {
override func view() -> UIView! {
return SharedElementGroup()
}
override func batchDidComplete() {
}
}
class SharedElement: RCTView {
private var identifier: String?
private var airbnbInstanceId: String?
func setIdentifier(identifier: String!) {
self.identifier = identifier
}
func setAirbnbInstanceId(airbnbInstanceId: String!) {
self.airbnbInstanceId = airbnbInstanceId
}
override func insertReactSubview(subview: UIView, atIndex index: Int) {
super.insertReactSubview(subview, atIndex: index)
guard let seid = identifier, id = airbnbInstanceId else { return }
let vc = ReactNavigationCoordinator.sharedInstance.viewControllerForId(id)
vc?.sharedElementsById[seid] = WeakViewHolder(view: subview)
}
override func removeReactSubview(subview: UIView) {
super.removeReactSubview(subview)
guard let seid = identifier, id = airbnbInstanceId else { return }
let vc = ReactNavigationCoordinator.sharedInstance.viewControllerForId(id)
vc?.sharedElementsById[seid] = nil
}
}
@objc(SharedElementManager)
class SharedElementManager: RCTViewManager {
override func view() -> UIView! {
return SharedElement()
}
override func batchDidComplete() {
}
}
// This would be in our navigation module where we actually run the `push`.
func push(screenName: String, withProps props: [String: AnyObject], options: [String: AnyObject], resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
DDLogVerbose("push \(screenName)")
dispatch_async(dispatch_get_main_queue()) {
let animated = true // TODO(lmr): grab off of options
guard let vc = self.coordinator.topViewController() else { return }
guard let nav = vc.navigationController else { return }
let pushed = ReactViewController(moduleName: screenName, props: props)
var makeTransition: (() -> ReactSharedElementTransition)? = nil
if let transitionGroup = options["transitionGroup"] as? String {
if let from = vc as? ReactAnimationFromContentVendor {
makeTransition = {
return ReactSharedElementTransition(
transitionGroup: transitionGroup,
fromViewController: from,
toViewController: pushed as ReactAnimationToContentVendor,
style: ReactAnimationStyle(duration: 0.5)
)
}
}
}
self.coordinator.registerFlow(pushed, resolve: resolve, reject: reject)
nav.pushReactViewController(pushed, animated: animated, makeTransition: makeTransition)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment