React Native animated bottom modal in a Native Stack Navigator

Cristian
Written by Cristian on
React Native animated bottom modal in a Native Stack Navigator

If you’re using the React Navigation library and you want to show a bottom modal you cannot use the useCardAnimation() hook to know the state of the transition, this hook is not available in native navigators.

You can implement a gesture-aware bottom modal with a transparent background using the bare minimum:

  • RN Animated API
  • yarn add react-native-gesture-handler

Declare the GModal in your Native Stack navigator. If you plan to have multiple modals feel free to include all of them in <RootStack.Group>. All the screenOptions values will automatically be passed down to all of the children.

<RootStack.Navigator>
<RootStack.Screen name="other-screen" component={OtherScreen} />
<RootStack.Group
screenOptions={{
presentation: 'containedTransparentModal',
animation: 'slide_from_bottom',
}}>
<RootStack.Screen name="g-modal" component={GModal} />
</RootStack.Group>
</RootStack.Navigator>
view raw Step-1.tsx hosted with ❤ by GitHub

Create a new GModal.tsx file and let’s start with some imports.

import React, { useCallback } from 'react';
import { Animated, View } from 'react-native';
import {
HandlerStateChangeEvent,
PanGestureHandler,
} from 'react-native-gesture-handler';
view raw Step-2.tsx hosted with ❤ by GitHub

Add a root view.

return (
<View
style={{
flex: 1,
backgroundColor: 'blue',
justifyContent: 'flex-end',
}}>
{/*
this view will cover the entire screen
when the modal is presented
*/}
</View>
);
view raw Step-3.tsx hosted with ❤ by GitHub

Add a <PanGestureHandler> with an animated content view, this <Animated.View> will be translated on Y axis using the gesture data provided by the PanGestureHandler.

return (
<View
style={{
flex: 1,
backgroundColor: "blue",
justifyContent: "flex-end",
}}
>
<PanGestureHandler>
<Animated.View
style={[
{
borderTopStartRadius: 20,
borderTopEndRadius: 20,
backgroundColor: "red",
paddingTop: 8,
paddingHorizontal: 24,
justifyContent: "center",
alignItems: "center",
},
]}
>
<View
style={{ width: "100%", height: 300, backgroundColor: "orange" }}
/>
</Animated.View>
</PanGestureHandler>
</View>
);
view raw Step-4.tsx hosted with ❤ by GitHub

assets/images/g-modal-2.png

Declare a variable to hold the current Y value and some functions that will be called by the PanGestureHandler.

// Gesture's Y axis value, will be used to animate our content view; up and downd.
const translateY = React.useRef(new Animated.Value(0)).current;
// This will be fired at least 60 times per second with an event containing
// gesture location data including the new Y axis value.
const onPanGestureEvent = Animated.event(
[
{
nativeEvent: {
translationY: translateY,
},
},
],
{ useNativeDriver: true }
);
const closeModal = useCallback(() => {
navigation.goBack();
}, [navigation]);
// Called when the user lifted his finger from the screen.
const onPanGestureEnd = useCallback(
(event: HandlerStateChangeEvent) => {
// If the user swiped down more than 150 points, close the modal.
if (event.nativeEvent.translationY > 150) {
closeModal();
return;
}
// This is optional, you can try to see how it looks without it.
Animated.spring(translateY, {
toValue: 0,
useNativeDriver: true,
friction: 5,
tension: 10,
}).start();
},
[closeModal, translateY]
);
return (
<View
style={{
flex: 1,
backgroundColor: "blue",
justifyContent: "flex-end",
}}
>
<PanGestureHandler
onEnded={onPanGestureEnd}
onGestureEvent={onPanGestureEvent}
>
<Animated.View
style={[
{
borderTopStartRadius: 20,
borderTopEndRadius: 20,
backgroundColor: "red",
paddingTop: 8,
paddingHorizontal: 24,
justifyContent: "center",
alignItems: "center",
},
{
transform: [
{
translateY: translateY,
},
],
},
]}
>
<View
style={{ width: "100%", height: 300, backgroundColor: "orange" }}
/>
</Animated.View>
</PanGestureHandler>
</View>
);
view raw Step-5.tsx hosted with ❤ by GitHub

Take a look and play a bit with this.

Let’s change the blue background, in the root view replace the blue value with transparent and you will see the content beneath.

If you open the modal and drag it up as much as you can, at the bottom of the modal, because it’s transparent you can see the parent’s page content.

Let’s create a new component to fix this.

const GBottomInfiniteView = React.memo(({ translateY }: any) => {
return (
<Animated.View
style={[
{
zIndex: 0,
position: 'absolute',
width: '100%',
height: 1000, // it can be any big value
backgroundColor: 'purple', // change it to `white` after you saw it on your device
},
{
transform: [
{
translateY: translateY.interpolate({
inputRange: [0, 1000],
outputRange: [1000, 2000],
}),
},
],
},
]}
/>
);
});
view raw Step-6.tsx hosted with ❤ by GitHub

This component will be placed as the last child in the root view:

return (
{/* [...] */}
</PanGestureHandler>
<GBottomInfiniteView translateY={translateY} />
</View>
);
view raw Step-7.tsx hosted with ❤ by GitHub

Add a drop shadow for our content view so we can see the edges more clearly.

{/* [...] */}
<Animated.View
style={[
{
backgroundColor: 'white',
paddingTop: 8,
paddingHorizontal: 24,
justifyContent: 'center',
alignItems: 'center',
// ios shadow
shadowColor: '#101010',
shadowOffset: { width: 4, height: 8 },
shadowOpacity: 0.25,
shadowRadius: 12,
// android shadow
elevation: 12,
},
{
transform: [
{
translateY: translateY,
},
],
},
]}>
<View
style={{ width: '100%', height: 300, backgroundColor: 'red' }}
/>
</Animated.View>
{/* [...] */}
view raw Step-8.tsx hosted with ❤ by GitHub

It’s cool to swipe it down to close it but we also want the modal to be dismissed if we click on the transparent background. Add a <Pressable> to intercept some click events and link it to the already defined closeModal() function.

import { Pressable } from 'react-native';
// [...]
return (
<Pressable onPress={closeModal} style={{ flex: 1 }}>
<View
style={{
flex: 1,
backgroundColor: 'transparent',
justifyContent: 'flex-end',
}}>
<PanGestureHandler
{*/ [...] */}
view raw Step-9.tsx hosted with ❤ by GitHub

One problem here. If you click on the red view, the onPress will be called and the modal will be closed, if you don’t want this, make sure to add another <Pressable> over <PanGestureHandler>.

// [...]
return (
<Pressable onPress={closeModal} style={{ flex: 1 }}>
<View
style={{
flex: 1,
backgroundColor: 'transparent',
justifyContent: 'flex-end',
}}>
<Pressable>
{*/ This 👆 Pressable will take in all the touch events happening inside. */}
<PanGestureHandler
onEnded={onPanGestureEnd}
onGestureEvent={onPanGestureEvent}>
{*/ [...] */}
view raw Step-10.tsx hosted with ❤ by GitHub

You’re pretty much done! Watch this short demo with a polished Logout Modal on Android.

Check out the full implementation here.

Feel free to write your question in the comments section bellow. 👇

Comments