Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
lintonye opened this issue Feb 4, 2017 · 60 comments

Comments

@lintonye
Copy link

lintonye commented Feb 4, 2017

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.

@ericvicenti
Copy link
Contributor

ericvicenti commented Feb 4, 2017

I'm excited to see progress on the shared element transitions!

The described API seems problematic to me because Image does not have a animateId prop.

Instead, maybe you could render something like this:

<SharedElement id={`image-${photo.url}`}>
  <Image />
</SharedElement>

This element would use context to communicate with the navigator about layout and animation.

@lintonye
Copy link
Author

lintonye commented Feb 4, 2017

@ericvicenti Hmm, I thought the prop will be stored in the React element even if it's not declared in propTypes? I was able to see any arbitrary prop passed into the component in my debug logs.

Yup wrapping it in a custom component was my approach described in the blog. I was hoping to simplify it more. :)

But this way looks more hopeful to implement than the tricky tree traversal. I call it TransitionView since it can be other kind of animations.

<TransitionView id={`image-${photo.url}`}>
  <Image />
</TransitionView>

@lintonye
Copy link
Author

lintonye commented Feb 4, 2017

@ericvicenti I fleshed out the API a bit more. It seems fairly doable now. Let me know what you think!

@ericvicenti
Copy link
Contributor

I really like the idea of having Transition.View, but the rest of the transition configuration API may be super tricky.

Maybe this could be added as a feature of CardStack, such that CardStack.SharedView can be rendered inside the screen, and the rest of the transition would be automatic.

@lintonye
Copy link
Author

lintonye commented Feb 7, 2017

@ericvicenti It's indeed an ambitious goal to support generic transitions. Would you mind elaborate which parts of the API look tricky and why?

@ericvicenti
Copy link
Contributor

The concept of a TransitionView seems very fuzzy in my mind. Could something like CardStack be implemented with these transition components? How would that look?

@lintonye
Copy link
Author

lintonye commented Feb 17, 2017

I think we can define TransitionViews to be anything that need to be animated during the transition. Perhaps we can have an option to animate them either on the overlay, or in-place.

Under the hood, we could reuse most code of CardStack, just extract its animation code into transitions. Either use a Transition.View as the root of the scenes, or use a special id to identify "scene views" and headers, and then:

const Slide = (filter) => (duration) => (
  {
    createAnimations(viewsOnFromRoute, viewsOnToRoute) { ... }
  }
);

const SlideScene = Slide('$sceneRoot');
const FadeScene = Fade('$sceneRoot');

const transitions = [
  {from: "PhotoGrid", to: 'PhotoDetail', transition: together(SlideScene(), sq(Idle(0.3), FadeScene()))},
]

Does this make sense?

@ericvicenti
Copy link
Contributor

I am still a bit confused. What would the CardStack component look like, if it was built out of TransitionViews? I was expecting your example to have JSX.

@lintonye
Copy link
Author

What about this? Basically wrapping existing scenes/header with Transition.View.

class CardStack {
  _render(props) {
    ....
    return (
      <View>
        <View>
          {props.scenes.map(scene => (
            <Transition.View id={`$scene-${scene.route.routeName}`}>
              { this._renderScene(...) }
            </Transition.View>
          ))}
        </View>
        {
          floatingHeader && (
            <Transition.View id="$floatingHeader">
              { floatingHeader }
            </Transition.View>
          )
        }
        { transitionOverlay }
      </View>
    )
  }
}

@lintonye
Copy link
Author

lintonye commented Feb 22, 2017

Update: I'm working on a proof of concept here: lintonye/react-navigation@master...multi-transitions

Haven't got it fully working yet, but it still seems doable. The API is fairly close to what's specified above except that I haven't started on transition composition yet. Example: shared elements transition

You can see that Card.js is updated to use Transition.View instead of Animated.View and CardStack.js is updated to measure views, invoke transitions, and push the resulting animated styles to Card.

I'd appreciate any feedback if you could take a quick look (or maybe if you could spot silly errors etc.).

@lintonye
Copy link
Author

This is coming together in the branch above.

Here are a couple examples:

Of course there are quite a few glitches that need to be fixed, and the API needs some more clean up. But I'd accept this as a proof of concept.

I'm running out of time this week. Hopefully will get back to this next week.

Let me know your thoughts!

@lintonye
Copy link
Author

lintonye commented Mar 8, 2017

More updates. I managed to fix most glitches and the animation looks much better now! I also started the sequence composition code, and it looks pretty good.

Here's a demo: https://youtu.be/1wW7CTWtSME, and in slow-mo: https://youtu.be/aBCeNxsULso Notice the subtle difference between the enter and exit transitions.

All it takes to implement this is the code below:

const SharedImage = initTransition(Transitions.SharedElement, /image-.+/);
const CrossFadeScenes = initTransition(Transitions.CrossFade, /\$scene-.+/);
const ScaleFab = initTransition(Transitions.Scale, /fab-.+/);
const Idle = initTransition(createTransition({}), /x/);

const transitions = [
  { 
    from: 'ProductGallery', to: 'ProductDetail', 
    // [ SharedImage(0.7), 0.5 => CrossFadeScenes(0.2))] => ScaleFab(0.3)
    transition: sequence(together(SharedImage(0.7), sequence(Idle(0.5), CrossFadeScenes(0.2))), ScaleFab(0.3)),
    config: { duration: 5800 },
  },
  { 
    from: 'ProductDetail', to: 'ProductGallery', 
    transition: together(SharedImage(1), CrossFadeScenes(0.2), ScaleFab(0.3)), 
    config: { duration: 5450 },
  },
];

How do you like it?

@migueloller
Copy link

@lintonye, as a potential consumer of the API (and not a contributor) this looks very elegant. Great work!

@ericvicenti
Copy link
Contributor

I see your updated proposal at the top of this issue, and it looks very powerful!

But I am concerned about the added complexity. It looks like a very tricky API to learn. What if we started by adding shared elements, and eventually consider adding transitions to navigationOptions?

cc @brentvatne, @satya164, @grabbou

@lintonye
Copy link
Author

@ericvicenti Thanks for the feedback.

I understand your concern if you meant the added complexity in the implementation. It sounds a good approach to release the shared elements first to help stabilizing the implementation before exposing transitions as a general API.

Regarding the learning complexity though, which part do you find tricky to learn? I'd appreciate some detailed feedback and suggestions. As far as I can see, for doing the same thing (i.e. choreographing custom transitions), this API is way easier than the current approach of rolling up custom transitioners.

What's the best approach to test the learnability? I guess we'd have to get people to use it somehow?

@migueloller migueloller mentioned this issue Mar 17, 2017
9 tasks
@getnashty
Copy link

@lintonye looking good to me so far! Any word on this?

@tlvenn
Copy link

tlvenn commented Mar 27, 2017

It indeed does look good and I personally dont feel the API overwhelming or complicated. The surface API seems to capture just what is needed and in a pretty expressive / declarative manner which is a good thing imho.

@lintonye
Copy link
Author

Thanks for the feedback @getnashty @tlvenn. My schedule was packed last couple of weeks, which should improve starting this week. I'm planning to get back to this and create a PR with shared element transition first, and then figure out how to roll out the more general API (suggestions welcome).

@ragnorc
Copy link

ragnorc commented Apr 6, 2017

Any updates?

@lintonye
Copy link
Author

lintonye commented Apr 6, 2017

Shared element transition PR created! Feedback welcome!

@lintonye
Copy link
Author

lintonye commented Apr 6, 2017

The shared element transition PR also includes an implementation of the custom transition API as described in this issue. Feel free to play with it and I'd appreciate your feedback.

I should warn you though that the API is highly subject to change and there are bugs all over the place. :) Have fun!

@qrobin
Copy link

qrobin commented Jul 14, 2017

@voidstarfire maybe this can help you, it works on Android, but for all stack.

const Router = StackNavigator({
    // Screens here
},
{
    transitionConfig: () => ({
        screenInterpolator: sceneProps => {
            const { layout, position, scene } = sceneProps;
            const { index } = scene;

            const translateX = position.interpolate({
                inputRange: [ index - 1, index, index + 1 ],
                outputRange: [ layout.initWidth, 0, 0 ]
            });

            const opacity = position.interpolate({
                inputRange: [ index - 1, index - 0.99, index, index + 0.99, index + 1 ],
                outputRange: [ 0, 1, 1, 0, 0 ]
            });

            return { opacity, transform: [{ translateX }] };
        }
    }),
    initialRouteName : ' ... '
});

@Obooman
Copy link

Obooman commented Jul 15, 2017

Dumb questions

  1. Has the PR been accepted or when it will be accept ?
  2. Not considering the Shared Element transition,could I config single scene transition configs like transitionConfig when we init StackNavigator in navigationOptions object( or function )?

I found this issue from other issues which been closed because of duplicating with this issue and now I don't find a clear solution out.

@jonasluthi
Copy link

Thanks for the great work. Do you know if the PR will be accepted soon ?

@dzpt
Copy link

dzpt commented Jul 29, 2017

@dannycochran hey, can i use transitionConfig for particular navigate action?
i want to disable transition at the beginning, then enable it again #2086

@superandrew213
Copy link

@lintonye what is the status on this? Are you still working on it?

@farzd
Copy link

farzd commented Aug 11, 2017

Wow, its been almost 6 months...

@joshjhargreaves
Copy link

@farzd, with all due respect, let’s keep it constructive.

@acomito
Copy link

acomito commented Sep 3, 2017

any status update on shared element transitions?

@JCMais
Copy link

JCMais commented Sep 5, 2017

Can we please get an update on this?

@kelset
Copy link

kelset commented Oct 7, 2017

For updates refer to #2585

@lintonye
Copy link
Author

lintonye commented Oct 7, 2017 via email

@hoanganhbk94
Copy link

@lintonye I only want to remove animate when call navigation.navigate(). It is same as navigation.pushViewController(controller: A, animate: false) in iOS. Can you help me?

@brentvatne
Copy link
Member

@lintonye - I know this is a year old and you may not be interested anymore, apologies for the lack of management on this project over the last year. if you are still wanting to push this through, could you re-create this RFC on https://github.com/react-navigation/rfcs please?

@IjzerenHein
Copy link
Contributor

Hi guys, please have a look at react-native-magic-move. As of 0.4 it supports react-navigation scene switches.

https://github.com/IjzerenHein/react-native-magic-move

ezgif com-video-to-gif

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests