#TECH

Implementing Shared Element Transition in React native

 

Shared element transition is a great way to add animations between activity transitions. I didn’t realize I had been using them all along until one day when I read about them in one of the blog posts. It piqued my interest and I wanted to learn to implement them in my project.

So, I tried to give it a go. After reading a bit about shared transition, I realized that it is just not the transition between two screens. It has all the concepts like micro-interactions, animation affordances, and making user interactions seamless.

The reason why shared transitions are needed is because they help the users focus more on the content rather than the animation. In normal screen transitions, one screen goes and another appears, just like turning the pages of a book.

For example:- In a mobile app, when a user taps on a list item and a detail screen opens up, we suddenly lose the flow, unless the app provides some redundant information about the list item tapped on the subsequent screen.

This can be solved with the help of shared transitions where the tapped list item always remains in front of users’ eyes and the detail part comes into focus.

I have taken this basic example here in this blog and will try to show how we can do that.

Steps for implementing shared transition in React Native are as follows–

  1. The user selects an item from the list.
  2. Get the width, height and coordinate for the selected item.
  3. Quickly animate the opacity of the exit screen from 1 to 0.
  4. Employ a dummy transition layer with a replica for the selected item. This transition layer is absolutely positioned.
  5. From step 2, we have all the information required for the starting state of the animation.
  6. Now the next step is to determine the final position, i.e, position of the shared element on the next screen. As most of the time, the shared element appears as the heading or feature image for which we can easily assign a static value.
  7. So, we have the starting and final position required for the animation, the next step is to actually animate the transition screen.
  8. When animation is about to complete, quickly change the opacity of the detail screen from 0 to 1 and remove the transition layer.
  9. To close the detail screen, run animation in the reverse direction (1–0).

Steps in Action

The result of the above-mentioned steps is a nice shared element transition as seen in the below video.

Shared transition determines how common elements like title, logo are shared between two screens. However, due to the virtual tree layout structure, it’s not possible to share two elements (nodes) between two screens (tree).

But this limitation can’t stop us from using this powerful animation tool from guiding the user through context switching as screen changes. We employ a series of animations for both exit and entry screens to make an illusion of element sharing.

Enough with theory, we have our empirical formula, let’s jump straight to coding.

1. Designing our initial and final screen

 

render() {
    return (
      <View style={{flex: 1}}>
        <SafeAreaView />
        <View style={Styles.containerStyle}>
          {/* Listing part */}

          {BmiData.map((item, index) => {
            return <BMICard key={index} data={item} index={index} />;
          })}

          {/* Detail screen */}
          <View style={[Styles.absoluteStyle]}>
            <BmiTable
              index={0}
              data={BmiData[0]}
              animatedValue={this.state.animatedValue}
            />
          </View>

          {/* Transition layer */}
        </View>
      </View>
    );
  }

As you can see, we have the listing and detail part on the same screen. This completes our UI part.

2. Getting the position of the selected card

return (
    <TouchableOpacity
      style={[
        Styles.bmiCardContainerStyle,
        style,
        {
          borderBottomLeftRadius: borderRadius,
          borderBottomRightRadius: borderRadius,
        },
      ]}
      activeOpacity={0.9}
      onPress={() => {
        if (cardRef) {
          cardRef.measure((x, y, width, height, pageX, pageY) => {
            const layout = {x: pageX, y: pageY, width, height};
            if (onCardClicked) {
              onCardClicked({data, index, layout});
            }
          });
        }
      }}
      ref={(ref) => {
        cardRef = ref;
      }}>

 

When a user has selected any card, we are calling “measure()” on the top view reference inside the Card. measure() is an async function which returns height, width, pageX, and pageY. PageX and PageY are x and y coordinates, respectively relative to the screen. We need this to have an absolute position transition layer. We receive data, index and layoutParam as a callback. These all are essential information to produce a duplicate of selected card on the transition layer. We receive data from the callback and store it in state. It is necessary because we also want our animation to reverse when the detail page is closed. For that we need an actual view position.

onCardClicked = ({data, index, layout}) => {
    this.state.animatedValue.setValue(0);
    this.setState(
      {
        startTransition: true,
        selectedIndex: index,
        selectedViewLayoutParam: layout,
        transitionCompleted: false,
        isAnimating: true,
      },
      () => {
        this.startAnimation();
      },
    );
  };

 

3. Now for the transition layer

From the previous step we have the absolute position of the clicked card as well as its data. Now, we can make a view which is absolutely positioned, having its top at selectedViewLayoutParam.y and left at selectedViewLayoutParam.x changing its opacity to 1. So we have our transition layer just over our original view.

{/* Transition layer */}
          {startTransition && (
            <React.Fragment>
              <Animated.View style={transitionLayerCardStyle}>
                <BMICard
                  animating={animatedValue}
                  index={selectedIndex}
                  data={BmiData[selectedIndex]}
                />
              </Animated.View>
            </React.Fragment>
          )}

 

4. Next thing is to animate our transition layer

We know our initial position, our final position (in most cases static). Let’s animate it

const ANIMATION_DURATION = 500;
const TRANSITION_TO_SCREEN_CUTOFF_VALUE = 0.001;
.
.
.

startAnimation = () => {
    Animated.timing(this.state.animatedValue, {
      toValue: 1,
      duration: ANIMATION_DURATION,
      useNativeDriver: false,
    }).start(() => {
      this.setState({
        transitionCompleted: true,
        isAnimating: false,
      });
    });
 };
.
.
.

getTransitionCardStyle = () => {
    const {selectedViewLayoutParam} = this.state;
    const initialPosition = selectedViewLayoutParam.y - 15;
    const finalPosition = 101;
    const top = this.state.animatedValue.interpolate({
      inputRange: [0, 1],
      outputRange: [initialPosition, finalPosition],
    });

    const opacityInterpolation = this.state.animatedValue.interpolate({
      inputRange: [
        0,
        TRANSITION_TO_SCREEN_CUTOFF_VALUE,

        1 - TRANSITION_TO_SCREEN_CUTOFF_VALUE,
        1,
      ],
      outputRange: [0, 1, 1, 0],
    });

    return {
      position: 'absolute',
      top,
      left: selectedViewLayoutParam.x,
      opacity: opacityInterpolation,
      width: selectedViewLayoutParam.width,
    };
  };

 

We are animating the top position of our transition layer from start to final. We are also changing its opacity from 0 to 1 and for the listing component from 1 to 0. We want the transition layer to appear as soon as the card is clicked and disappear when it is just about to reach final position that’s why we have a very small cutoff value. If I turn off opacity animation, let me show what we have created so far.

const opacityInterpolation = this.state.animatedValue.interpolate({inputRange: [0, TRANSITION_TO_SCREEN_CUTOFF_VALUE,    
1 - TRANSITION_TO_SCREEN_CUTOFF_VALUE,1],outputRange: [0, 1, 1, 0],});

 

5. Animating detail screen

On Detail screen do this

function getSharedElementOpacityStyle(animatedValue) { const opacityInterpolation = animatedValue.interpolate({     inputRange: [0, 0.995, 1],
  outputRange: [0, 0, 1],
 }); return {
  opacity: opacityInterpolation,
  paddingHorizontal: 0,// for removing horizontal padding as I have common styling for container components
 };
}

 

This part is pretty straight forward. We just have to change the opacity of detail page from 1 to 0 when animation is about to finish. This is the same duration in which the transition layer is removed. Also note that, animatedValue is received as a prop on detail page because we want all the animations to be synchronized.

6. Closing detail screen

When we want to close the detail screen, we just have to run animation from 1 to 0 and all is done with magic. No more code required.

7. Final output

As it is just a way around to achieve shared-transition effect and not actual screen transition. I would also like to share a few limitations.

  1. There is not an actual screen transition. So you have to handle the back button explicitly.
  2. All the 3 layers i.e, initial listing screen, intermediate transition layer, and final detail screen sits on the same screen.
  3. You have to do everything manually. But this has a bright side too. As we are in control of animation, we can also control other parts of the screen and not just shared elements. Look carefully at disclosure arrow rotation or transition of table. 🙂

Find final code at: https://github.com/NitishQuovantis/SharedTransitionBlog

 

 

You might also like