How to use shared element transitions in React Native
Originally Published at Logrocket
Transitions in mobile applications provide design continuity. This continuity is provided by connecting common elements from one view to the next while navigating in the app. This tutorial is going to provide a guide for you who is a React Native developer and is able to create such interfaces and make sure they are tangible.
Source code is available at this GitHub repo.
What are shared elements transition?
🔗Transitions between different views or activities involve enter and exit transitions that animate the entire view hierarchies independent of each other. There are times when two different views in continuity have common elements. Providing a way to transit these common elements from one view to the second view and back, emphasizes the continuity between transitions. The nature of these transitions maintain focus for the end-users on the content and provides a seamless experience. A shared element transition determines how two different views share one or elements to maintain the focus and experience.
Pre-requisites
🔗Before you begin, please make sure to have the following installed on a local environment:
- Node.js version >= 12.x.x installed
- Access to one package manager such as npm or yarn or npx
- expo-cli installed, or use npx
Do note that to demonstrate I’ll be using an iOS simulator. If you prefer to use an Android device or an emulator, the code snippets shared in this post will run the same.
Install shared element transition libraries
🔗To get started, let's create a new React Native project using expo-cli
. From a terminal window, execute the command below and then navigate inside the newly created project directory. After navigating, install the libraries that are required in order to create shared element transitions. Let's use react-navigation
from one screen to another using a stack navigation pattern.
To install the React Navigation library, please take a look at the following instructions from the official documentation. These dependencies change with time.
npx expo init shared-element-transitionscd shared-element-transitionsyarn add @react-navigation/native react-native-animatableexpo install react-native-gesture-handler react-native-reanimated react-native-screens react-native-safe-area-context @react-native-community/masked-viewyarn add react-native-shared-element react-navigation-shared-element@next
After installing these libraries, let's checkout how to run the Expo app. From the terminal, run the yarn start
command to trigger a build for the Expo app. Then depending on the simulator or the device, please select the correct option from the terminal prompt. For example, to run this app in its initial state on an iOS simulator, press i
.
Here is how the output on an iOS simulator is shown:
This output verifies that the Expo app is up and running.
Create a home screen
🔗The transition in this example app is going to be between a home screen and a details screen. The home screen is going to be a scrollable list of images and some data. I am going to use a set of the mock data array. You are free to use whatever data you might want to try out. Without bothering about the data set, you can use the mock data. Create a new directory called config/
and inside it create a new file called data.js
with the following array and objects:
1export const data = [2 {3 id: '1',4 title: 'Manarola, Italy',5 description: 'The Cliffs of Cinque Terre',6 image_url:7 'https://images.unsplash.com/photo-1516483638261-f4dbaf036963?ixid=MXwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHw%3D&ixlib=rb-1.2.1&auto=format&fit=crop&w=633&q=80',8 iconName: 'location-pin'9 },1011 {12 id: '2',13 title: 'Venezia, Italy',14 description: 'Rialto Bridge, Venezia, Italy',15 image_url:16 'https://images.unsplash.com/photo-1523906834658-6e24ef2386f9?ixlib=rb-1.2.1&ixid=MXwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHw%3D&auto=format&fit=crop&w=630&q=80',17 iconName: 'location-pin'18 },19 {20 id: '3',21 title: 'Prague, Czechia',22 description: 'Tram in Prague',23 image_url:24 'https://images.unsplash.com/photo-1513805959324-96eb66ca8713?ixid=MXwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHw%3D&ixlib=rb-1.2.1&auto=format&fit=crop&w=634&q=80',25 iconName: 'location-pin'26 }27];
After that create a new directory called screens/
where the two app screens are going to live. Create a file inside it called HomeScreen.js
and import the following statements.
1import React from 'react';2import {3 ScrollView,4 Text,5 View,6 TouchableOpacity,7 Image,8 Dimensions9} from 'react-native';10import { StatusBar } from 'expo-status-bar';11import { SimpleLineIcons } from '@expo/vector-icons';12import { data } from '../config/data';
Using the Dimensions
API from React Native, let's define the initial width and height of the image component. In the code snippet below, I am calculating both the width and the height using the width
of the screen.
1const { width } = Dimensions.get('screen');23const ITEM_WIDTH = width * 0.9;4const ITEM_HEIGHT = ITEM_WIDTH * 0.9;
The HomeScreen
component is going to be a functional React component that accepts one prop called navigation
. It will allow the navigation from the Home screen to the DetailScreen
. In any React Native app, the React Navigation library provides a context that further gives access to the navigation
object as a prop automatically. The prop contains various functions that dispatch navigation actions.
1export default function HomeScreen({ navigation }) {2 return (3 <View style={{ flex: 1, backgroundColor: '#0f0f0f' }}>4 <StatusBar hidden />5 {/* Header */}6 <View style={{ marginTop: 50, marginBottom: 20, paddingHorizontal: 20 }}>7 <Text style={{ color: '#888', textTransform: 'uppercase' }}>8 Saturday 9 January9 </Text>10 <Text style={{ color: '#fff', fontSize: 32, fontWeight: '600' }}>11 Today12 </Text>13 </View>14 )1516}171819
This functional component is going to render the header stating some dummy information to display and beneath it, a ScrollView
to scroll through a list of images. Each image displays an icon and some information regarding what the image is about. This image and the text on it will play a huge role later when a transition is going to happen between the home and detail screen.
Inside the ScrollView
component, let's render the mock data using JavaScript's map()
method. If you are injecting data from a REST API that is hosted somewhere and you are not sure about the number of items in that particular data set, please use a FlatList
component from React Native instead of ScrollView
.
1return (2 {/* Scrollable content */}3<View style={{ flex: 1, paddingBottom: 20 }}>4 <ScrollView5 indicatorStyle='white'6 contentContainerStyle={{ alignItems: 'center' }}7 >8 {data.map(item => (9 <View key={item.id}>10 <TouchableOpacity11 activeOpacity={0.8}12 style={{ marginBottom: 14 }}13 onPress={() => navigation.navigate('DetailScreen', { item })}14 >15 <Image16 style={{17 borderRadius: 14,18 width: ITEM_WIDTH,19 height: ITEM_HEIGHT20 }}21 source={{ uri: item.image_url }}22 resizeMode='cover'23 />24 <View25 style={{26 position: 'absolute',27 bottom: 20,28 left: 1029 }}30 >31 <View style={{ flexDirection: 'row' }}>32 <SimpleLineIcons size={40} color='white' name={item.iconName} />33 <View style={{ flexDirection: 'column', paddingLeft: 6 }}>34 <Text35 style={{36 color: 'white',37 fontSize: 24,38 fontWeight: 'bold',39 lineHeight: 2840 }}41 >42 {item.title}43 </Text>44 <Text45 style={{46 color: 'white',47 fontSize: 16,48 fontWeight: 'bold',49 lineHeight: 1850 }}51 >52 {item.description}53 </Text>54 </View>55 </View>56 </View>57 </TouchableOpacity>58 </View>59 ))}60 </ScrollView>61</View>);
Create a detail screen
🔗The DetailScreen
component is going to render the details for each image that is part of the scroll list on the home screen. On this screen, an image is shown with a back navigation button that is positioned on the top of the screen. It receives the data in form of an item
object that is destructured using route.params
from React Navigation library. Beneath the image, it is going to show the title that will be shared with the home screen and some dummy text.
Create a new file called DetailScreen.js
inside the screens/
directory and add the following code snippet:
1import React, { useRef } from 'react';2import {3 StyleSheet,4 Text,5 View,6 ScrollView,7 Image,8 Dimensions9} from 'react-native';1011import { SimpleLineIcons, MaterialCommunityIcons } from '@expo/vector-icons';1213const { height } = Dimensions.get('window');14const ITEM_HEIGHT = height * 0.5;1516const DetailScreen = ({ navigation, route }) => {17 const { item } = route.params;1819 return (20 <View style={{ flex: 1, backgroundColor: '#0f0f0f' }}>21 <Image22 source={{ uri: item.image_url }}23 style={{24 width: '100%',25 height: ITEM_HEIGHT,26 borderBottomLeftRadius: 20,27 borderBottomRightRadius: 2028 }}29 resizeMode="cover"30 />31 <MaterialCommunityIcons32 name="close"33 size={28}34 color="#fff"35 style={{36 position: 'absolute',37 top: 40,38 right: 20,39 zIndex: 240 }}41 onPress={() => {42 navigation.goBack();43 }}44 />45 <View46 style={{ flexDirection: 'row', marginTop: 10, paddingHorizontal: 20 }}47 >48 <SimpleLineIcons size={40} color="white" name={item.iconName} />49 <View style={{ flexDirection: 'column', paddingLeft: 6 }}>50 <Text51 style={{52 color: 'white',53 fontSize: 24,54 fontWeight: 'bold',55 lineHeight: 2856 }}57 >58 {item.title}59 </Text>60 <Text61 style={{62 color: 'white',63 fontSize: 16,64 fontWeight: 'bold',65 lineHeight: 1866 }}67 >68 {item.description}69 </Text>70 </View>71 </View>72 <ScrollView73 indicatorStyle="white"74 style={{75 paddingHorizontal: 20,76 backgroundColor: '#0f0f0f'77 }}78 contentContainerStyle={{ paddingVertical: 20 }}79 >80 <Text81 style={{82 fontSize: 18,83 color: '#fff',84 lineHeight: 24,85 marginBottom: 486 }}87 >88 Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do89 eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad90 minim veniam, quis nostrud exercitation ullamco laboris nisi ut91 aliquip ex ea commodo consequat. Duis aute irure dolor in92 reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla93 pariatur. Excepteur sint occaecat cupidatat non proident, sunt in94 culpa qui officia deserunt mollit anim id est laborum.95 </Text>96 <Text97 style={{98 fontSize: 18,99 color: '#fff',100 lineHeight: 24,101 marginBottom: 4102 }}103 >104 Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do105 eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad106 minim veniam, quis nostrud exercitation ullamco laboris nisi ut107 aliquip ex ea commodo consequat. Duis aute irure dolor in108 reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla109 pariatur. Excepteur sint occaecat cupidatat non proident, sunt in110 culpa qui officia deserunt mollit anim id est laborum.111 </Text>112 </ScrollView>113 </View>114 );115};116117export default DetailScreen;
Add navigation to the app
🔗To navigate from the home screen to the detail screen and back, the app needs to have a navigation flow. This is going to be provided by createSharedElementStackNavigator
method from react-navigation-shared-element
module. It contains the React Navigation library for react-native-shared-element
. This method allows us to create a stack-navigator which is the initial process of sharing elements between two separate screens. It wraps each route with the shared element and it detects route changes to trigger the transitions. The process of defining the navigation flow using this method is similar to React Navigation's stack-navigator module.
Create a new directory called navigation/
and inside it create a new file called RootNavigator.js
. Import the following statements and create an instance called Stack
of the createSharedElementStackNavigator
method. Then define the Root Navigator.
1import * as React from 'react';2import { NavigationContainer } from '@react-navigation/native';3import { createSharedElementStackNavigator } from 'react-navigation-shared-element';45import HomeScreen from '../screens/HomeScreen';6import DetailScreen from '../screens/DetailScreen';78const Stack = createSharedElementStackNavigator();910export default function RootNavigator() {11 return (12 <NavigationContainer>13 <Stack.Navigator headerMode="none" initialRouteName="HomeScreen">14 <Stack.Screen name="HomeScreen" component={HomeScreen} />15 <Stack.Screen name="DetailScreen" component={DetailScreen} />16 </Stack.Navigator>17 </NavigationContainer>18 );19}
To see it in action, modify the App.js
file as shown below:
1import React from 'react';2import RootNavigator from './navigation/RootNavigator';34export default function App() {5 return <RootNavigator />;6}
Here is the result after this step in the iOS simulator:
Shared element mapping
🔗The image component is going to be responsible to support a seamless back and forth transition between home and detail screen. This transition should happen from the scroll grid to the detail screen and back to the relevant image. To make this happen, wrap the Image
component with <SharedElement>
and provide a unique id
to it in the HomeScreen
.
Also, make sure to import the <SharedElement>
component from the react-navigation-shared-element
module.
1import { SharedElement } from 'react-navigation-shared-element';23// Wrap the image component as4return (5 // ...67 <SharedElement id={`item.${item.id}.image_url`}>8 <Image9 style={{10 borderRadius: 14,11 width: ITEM_WIDTH,12 height: ITEM_HEIGHT13 }}14 source={{ uri: item.image_url }}15 resizeMode="cover"16 />17 </SharedElement>18);
The <SharedElement>
component accepts a prop called id
that is the shared id between the two screens. The child it is wrapped around is the actual component where the transition happens.
To enable the shared element transitions, the above process has to be followed in DetailScreen
.
1import { SharedElement } from 'react-navigation-shared-element';23// Wrap the image component as4return (5 // ...6 <SharedElement id={`item.${item.id}.image_url`}>7 <Image8 source={{ uri: item.image_url }}9 style={{10 width: '100%',11 height: ITEM_HEIGHT,12 borderBottomLeftRadius: 20,13 borderBottomRightRadius: 2014 }}15 resizeMode="cover"16 />17 </SharedElement>18);
To animate the transition between the home and the detail screens, define a sharedElements
configuration in the DetailScreen
component. This will map the transition of the Image
component between the two screens.
Before the export
statement in DetailScreen.js
add the code snippet:
1DetailScreen.sharedElements = route => {2 const { item } = route.params;3 return [4 {5 id: `item.${item.id}.image_url`,6 animation: 'move',7 resize: 'clip'8 }9 ];10};
The config object above triggers the transition effects on shared elements between screens based on the unique ID shared between those two screens. This is done by defining a property called id
.
The property animation
determines how the animation is going to happen when navigating between two screens. For example, in the above code snippet, the animation
has a value called move
. It is also the default value of this property. There are other values available such as fade
, fade-in
, and fade-out
. The property resize
is the behavior that determines the shape and size of the element should be modified or not. For example, in the above snippet, the value clip
adds a transition effect which is similar to a text reveal effect.
Here is the output after this step:
In the above example, please note that when the transition happens, the screen slides from left to right in between. To modify this behavior to apply transition effects of the shared elements, let's add an options
configuration object to the DetailScreen
. In Root Navigator file, add the following configuration:
1const options = {2 headerBackTitleVisible: false,3 cardStyleInterpolator: ({ current: { progress } }) => {4 return {5 cardStyle: {6 opacity: progress7 }8 };9 }10};1112// Then add it to the DetailScreen1314return (15 <Stack.Screen16 name="DetailScreen"17 component={DetailScreen}18 options={() => options}19 />20);
The cardStyleInterpolator
function specifies the interpolated styles for different parts of a card. It allows us to customize the transitions when navigating between two screens. It receives a property value called current.progress
that represents the animated node progress value of the current screen. Applying this value to the property opacity
changes the animated node to the value of animation defined in the shared element config object. Its cardStyle
property applies the style on the view that is representing the card.
Update Shared elements mapping
🔗In the previous demonstration, you can see that the transition on the image component is seamless but other components shared such as the location pin icon, the title and the description of the item between two screens is not.
To resolve this, let's map them using <SharedElement>
component. First, in home screen, modify the following components:
1return (2 // Icon3 <SharedElement id={`item.${item.id}.iconName`}>4 <SimpleLineIcons size={40} color='white' name={item.iconName} />5 </SharedElement>67 //Title8 <SharedElement id={`item.${item.id}.title`}>9 <Text10 style={{11 color: 'white',12 fontSize: 24,13 fontWeight: 'bold',14 lineHeight: 2815 }}16 >17 {item.title}18 </Text>19</SharedElement>2021 // Description22 <SharedElement id={`item.${item.id}.description`}>23 <Text24 style={{25 color: 'white',26 fontSize: 16,27 fontWeight: 'bold',28 lineHeight: 1829 }}30 >31 {item.description}32 </Text>33</SharedElement>34);
Similarly, modify the following elements in DetailScreen.js
file:
1// Icon2<SharedElement id={`item.${item.id}.iconName`}>3 <SimpleLineIcons size={40} color='white' name={item.iconName} />4</SharedElement>56// Title7<SharedElement id={`item.${item.id}.title`}>8 <Text9 style={{10 color: 'white',11 fontSize: 24,12 fontWeight: 'bold',13 lineHeight: 2814 }}15 >16 {item.title}17 </Text>18</SharedElement>1920// Description21<SharedElement id={`item.${item.id}.description`}>22 <Text23 style={{24 color: 'white',25 fontSize: 16,26 fontWeight: 'bold',27 lineHeight: 1828 }}29 >30 {item.description}31 </Text>32</SharedElement>
Then add the configuration:
1DetailScreen.sharedElements = route => {2 const { item } = route.params;3 return [4 {5 id: `item.${item.id}.image_url`,6 animation: 'move',7 resize: 'clip'8 },9 {10 id: `item.${item.id}.title`,11 animation: 'fade',12 resize: 'clip'13 },14 {15 id: `item.${item.id}.description`,16 animation: 'fade',17 resize: 'clip'18 },19 {20 id: `item.${item.id}.iconName`,21 animation: 'move',22 resize: 'clip'23 }24 ];25};
Here is the output after this step:
Delayed loading
🔗Shared element transitions are a great way to support a smooth end-user experience but it can become tricky when dealing with elements that need to be loaded before or after the transition happens. For example, in the previous demonstration, the back button renders before the transition happens. To control its behavior, let's animate it using the React Native Animatable library.
Import it inside the DetailScreen.js
file:
1import * as Animatable from 'react-native-animatable';
The close button icon is going to be wrapped inside the <Animatable.View>
. This component has a prop called delay
that delays the animation. Using a prop called duration
you can control the amount of time the animation will run. Values to both of these props are provided in milliseconds. Using a ref
value, the fadeOut
animation is applied on the icon. This animation method is asynchronous and thus, you can use the promise to navigate back to the home screen after the animation has successfully run. The argument passed to this animation method is in milliseconds.
1const DetailScreen = ({ navigation, route }) => {2 const buttonRef = React.useRef();34 return (5 <Animatable.View6 ref={buttonRef}7 animation="fadeIn"8 duration={600}9 delay={300}10 style={[StyleSheet.absoluteFillObject]}11 >12 <MaterialCommunityIcons13 name="close"14 size={28}15 color="#fff"16 style={{17 position: 'absolute',18 top: 40,19 right: 20,20 zIndex: 221 }}22 onPress={() => {23 buttonRef.current.fadeOut(100).then(() => {24 navigation.goBack();25 });26 }}27 />28 </Animatable.View>29 );30};
Here is the final output:
Conclusion
🔗I hope you had fun reading this tutorial. Sharing elements in between screens in React Native using the React Navigation Shared Element module makes both the process of development and end-user experience smooth. I would recommend you to check out the official documentation here for more information.
Source code is available at this GitHub repo.
More Posts
Browse all posts