Skip to content

Support customizable, complex transitions that animate individual views on a screen #175

@lintonye

Description

@lintonye

Update March 8, 2017: This RFC is based on an experimental implementation at lintonye/react-navigation@master...multi-transitions

Motivation

So far we only support simple transitions that animate the entire screen. It's desirable to allow highly customizable transitions that animate individual views on the screen. The shared elements transition is an example of this. It'd be fun if we could easily implement this or this.

The last two examples are perhaps difficult to implement even in native code where the APIs are not always straightforward to use. It'd be great if we could have a declarative API in RN to simplify that!

Proposed API

I'm hoping to minimize the impact on the code unrelated to transitions. We should be able to code our components as usual, and add some special markups to pick out the views we want to animate.

A simple example

const sharedImages = Transitions.sharedElement(/image-.+/);                    // ==> 1
const crossFadeScenes = Transitions.crossFade(/\$scene-.+/); 

class PhotoGrid extends Component {
  static navigationOptions: {
    cardStack: {
      transitions: [                                                           // ==> 2
        { 
          to: 'PhotoDetail', 
          transition: sequence(sharedImages(0.8), crossFadeScenes(0.2)),       // ==> 3
          config: { duration: 580 },
        },
      ]
    }
  }
  ....
  renderCell({url, title}) {
    return (
      <View>
        <Transition.Image id={`image-${url}`} source={{uri: url}}/>           // ==> 4
        <Text>{title}</Text>
      </View>
    );
  }
}

const PhotoDetail = ({photo}) => (
  <View>
    <Transition.Image id={`image-${photo.url}`} source={{uri: photo.url}}/>
    <Text>{photo.title}</Text>
    <Text>{photo.description}</Text>
  </View>
);
PhotoDetail.navigationOptions = {
  cardStack: {
    transitions: [
      { 
        to: 'PhotoGrid', 
        transition: together(crossFadeScenes(0.2), sharedImages(1))
        config: { duration: 580 },
      },
    ]
  }
};

Dissecting the example

1. Apply a regex to "bind" a transition

const sharedImages = Transitions.sharedElement(/image-.+/);

This creates a transition bound by the given regex. The created transition is only effective on "transition views" (see 4 below) with a matching id.

See the section "Discussions" at the end about why a regex is used.

2. transitions configuration in navigationOptions

We can declaratively specify transitions that originate from a screen:

  static navigationOptions: {
    cardStack: {
      transitions: [
        { 
          to: 'PhotoDetail', 
          transition: sequence(sharedImages(0.8), crossFadeScenes(0.2)),
          config: { duration: 580 },
        },
      ]
    }
  }

Perhaps transitions can be a function to allow more dynamic definitions:

  transitions: (toRouteName: string) => (
    toRouteName === 'PhotoDetail' && {
      transition: ...,
      config: ...,
    }
  )

3. Transition duration and Composition

Transitions can be composed using sequence() and together().

Bound transitions such as sharedImages and crossFadeScenes are functions that take a durationRatio: number parameter. The durationRatio is between 0 and 1 and defines the relative time the transition will play. The actual play time = config.duration * durationRatio.

If durationRatio is omitted, the transition will stretch till the end of the entire transition. For example, in sequence(sharedImages(0.8), crossFadeScenes()), the durationRatio of crossFadeScenes() is 0.2, whereas in together(sharedImages(0.8), crossFadeScenes()), it's 1.

4. Marking up "Transition Views"

Transition views are the views that are animated during transition.

The Transition.* components mirror the Animated.* components and API:

<Transition.View id="view1">
  <Text>Foo</Text>
  <Text>Bar</Text>
</Transition.View>

<Transition.Image id="image1" source={require('./img1.jpg')}/>

<Transition.Text id="image1">Foobar</Transition.Text>

// create a transition component from any component
Transition.createTransitionComponent(Component);

The only extra prop that a Transition.* component accepts is id: string, which is used by transitions to filter views that they want to animate.

The following transition views with special ids are already created in a CardStack:

  • $scene-${routeName}: the root view of each scene
  • $header-${routeName} or $header: the header on each scene, or the floating header if the headerMode is float
  • $overlay: the root of the overlay created during transition if any transition views are configured to be cloned.

Creating custom transitions

const MyTransition = createTransition({
  getItemsToClone(                           // ==> 1
    itemsOnFromRoute: Array<TransitionItem>,
    itemsOnToRoute: Array<TransitionItem>
  ): Array<TransitionItem> { ... },

  getItemsToMeasure(                         // ==> 2
    itemsOnFromRoute: Array<TransitionItem>,
    itemsOnToRoute: Array<TransitionItem>
  ): Array<TransitionItem> { ... },

  getStyleMap(                               // ==> 3
    itemsOnFromRoute: Array<TransitionItem>,
    itemsOnToRoute: Array<TransitionItem>
  ): ?TransitionStyleMap { 
    ....
    return {
      from: { 
        viewId1: {
          opacity: 1,
          scale: {
            inputRange: [0, 0.5, 1],
            outputRange: [0, 1, 1],
            easing: Easing.bounce,
          }
        },
        viewId2: { ... }
      },
      to: ....
    }
  },

  getStyleMapForClones(                      // ==> 4
    itemsOnFromRoute: Array<TransitionItem>,
    itemsOnToRoute: Array<TransitionItem>
  ): ?TransitionStyleMap { ... },

  canUseNativeDriver(): boolean {            // ==> 5
    return true; // returns true by default
  },
})

Then, MyTransition can be bound with a regex and used in navigationOptions:

const boundTransition = bindTransition(MyTransition, /image-.+/);

1. getItemsToClone

This function returns an array of TransitionItems, selected from itemsOnFromRoute and itemsOnToRoute.

When a TransitionItem is included in this array, its associated react element will be cloned on to an overlay that sits on top of everything. The overlay is only visible during the entire transition process (i.e, visible only when progress falls in (0, 1)).

No overlay will be created if none of the configured transitions specify at least one item to clone.

2. getItemsToMeasure

This function returns an array of TransitionItems, selected from itemsOnFromRoute and itemsOnToRoute.

When a TransitionItem is included in this array, it will contain a prop metrics: {x: number, y: number, width: number, height: number} when it's passed into getStyleMap. Currently metrics only includes the view's location in the window, but perhaps its location in its parent can be included as well.

Keep the number of items to measure small because the measuring process is slow which could cause the transition to lose frames.

3. getStyleMap

This is the main function that animates the transition views. It returns a TransitionStyleMap, whose shape is something like this:

{
  from: {                           // ==> a
    viewId1: {                      // ==> b
      opacity: 1,                   // ==> c
      scale: {                      // ==> d
        inputRange: [0, 0.5, 1],    
        outputRange: [0, 1, 1],
        easing: Easing.bounce,
      }
    },
    viewId2: { ... }
  },
  to: ....
}
  • a) from and to are two fixed keywords that tell if the concerned transition views are on the "from route" or "to route".
  • b) The id of the transition view as defined in <Transition.Image id="image1" />
  • c) Simple valued style prop can be used here.
  • d) To animate a style prop, instead of creating an Animated.Value, we just specify the parameters here. Under the hood, the transition library merges the ranges and creates an Animated.Value by interpolating position.
    • inputRange should be between 0 and 1. The default is [0, 1].
    • transform styles such as scale and translateX should be directly set here instead of embeded in a transform array.

Note, if an item is cloned, the library will always hide it during the transition regardless of the style returned from this function. This avoids showing duplicated views due to the clone on the overlay.

4. getStyleMapForClones

This function works in the same way as getStyleMap, except that it animates clones on the overlay. It will only receives items returned from getItemsToClone().

5. canUseNativeDriver

In a transition composition, this returns true only if all sub transitions return true. If this function isn't deinfed, it's default to true.

Discussions

Use of regex

A regex is used to select transition views instead of just a string because:

  1. It's convenient to create a single transition with a regex to catch all views that we want to animate in a similar way. For example, we can say crossFade(/\$scene-.+/) instead of crossFade('$scene-route1'), crossFade('$scene-route2'), ...
  2. To define a precise choreography of the animations, it's necessary to take into account multiple views all at once in a same transition. For example, say we want to implement a "staggered appear" for a grid of images, we can create a single transition that receives all images and apply a random start in their input ranges. This seems difficult to implement with other alternatives.

However, @ericvicenti expressed concerns about its impact on static analysis and developer ergonomics. Please comment if you have similar or other concerns.

Make SharedElement transition default

Since shared element is such a popular transition, we could consider to make it default whenever the app developer specifies a shared element.

<Transition.Shared.Image id='image1' />
  • If the above is specified in both from route and to route, we'll run sequence(sharedElement(0.8), crossFadeScenes(0.2)) and together(sharedElement(1), crossFadeScenes(0.2)) on the Back direction. Note the crossFadeScenes transition is necessary for the transition to look good.
  • If no Transition.Shared.Image is used, and no other transitions specified, we'll run the default CardStack transitions as implemented right now.

One downside: if the user defines a custom transition, the default sharedElement transition will not run even though the view is marked as Transition.Shared. This could cause confusions. But I think it should be minor since the developer is already aware of creating custom transitions, hence, she should as well remove the word Shared in the jsx.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions