Building an Animated Bottom Sheet in React NativeReanimated and React Native Gesture Handler
A critical aspect of any animation-based component is state management. In this code, the offset
variable is managed using Reanimated's useSharedValue
. This variable represents the current position of the bottom sheet.
const offset = useSharedValue(0);
The panGesture
gesture handler enables users to drag the bottom sheet up and down. It employs the Gesture.Pan()
method from React Native Gesture Handler to update the offset
value accordingly.
const panGesture = Gesture.Pan()
.onChange(event => {
// Update the offset based on the gesture's change in Y position.
})
.onFinalize(() => {
// Determine whether to close or open the sheet based on its final position.
});
The translateY
style is responsible for animating the sheet's vertical position based on the offset
value.
const translateY = useAnimatedStyle(() => {
return {
transform: [
{
translateY: offset.value,
},
],
};
}, []);
Full here:
// animated-bottom-sheet.tsx
// react
import React, {Fragment, ReactNode, useEffect} from 'react';
// modules
import {Pressable, StyleSheet} from 'react-native';
import Animated, {
FadeIn,
FadeOut,
runOnJS,
SlideInDown,
SlideOutDown,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from 'react-native-reanimated';
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
type AnimatedBottomSheetProps = {
isOpen: boolean;
backdropOnPress: () => void;
children: ReactNode;
};
const PressAnimated = Animated.createAnimatedComponent(Pressable);
const HEIGHT = 300;
const CLAMP = 20;
const AnimatedBottomSheet = (props: AnimatedBottomSheetProps) => {
const offset = useSharedValue(0);
const panGesture = Gesture.Pan()
.onChange(event => {
const offsetDelta = event.changeY + offset.value;
const clamp = Math.max(-CLAMP, offsetDelta);
offset.value = offsetDelta > 0 ? offsetDelta : withSpring(clamp);
})
.onFinalize(() => {
if (offset.value < HEIGHT / 3) {
offset.value = withSpring(0);
} else {
offset.value = withTiming(HEIGHT, {}, () => {
runOnJS(props.backdropOnPress)();
});
}
});
const translateY = useAnimatedStyle(() => {
return {
transform: [
{
translateY: offset.value,
},
],
};
}, []);
useEffect(() => {
function onOpen() {
if (props.isOpen) {
offset.value = 0;
}
}
onOpen();
}, [props.isOpen]);
if (!props.isOpen) {
return <Fragment />;
}
return (
<Fragment>
<PressAnimated
onPress={props.backdropOnPress}
entering={FadeIn}
exiting={FadeOut}
style={styles.backdrop}
/>
<GestureDetector gesture={panGesture}>
<Animated.View
entering={SlideInDown.springify().damping(15)}
exiting={SlideOutDown}
style={[styles.view, translateY]}>
{props.children}
</Animated.View>
</GestureDetector>
</Fragment>
);
};
const styles = StyleSheet.create({
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.45)',
zIndex: 1,
},
view: {
backgroundColor: 'white',
height: HEIGHT,
width: '100%',
position: 'absolute',
bottom: -CLAMP * 1.1,
zIndex: 1,
borderTopLeftRadius: 10,
borderTopRightRadius: 10,
},
});
export default AnimatedBottomSheet;
use the AnimatedBottomSheet component:
function App() {
const [isOpen, setIsOpen] = useState<boolean>(false);
return (
<GestureHandlerRootView style={{flex: 1}}>
<NavigationContainer>
<SafeAreaView style={{flex: 1, justifyContent: 'center'}}>
<Button
title="open"
onPress={() => setIsOpen(prevState => !prevState)}
/>
<AnimatedBottomSheet
isOpen={isOpen}
backdropOnPress={() => setIsOpen(prevState => !prevState)}>
{/*Your component in bottom sheet*/}
</AnimatedBottomSheet>
</SafeAreaView>
</NavigationContainer>
</GestureHandlerRootView>
);
}
export default App;