YOU'RE NOW READING

Bottom Sheet in React Native

calendar_today Posted 7 months ago · 6 minute-read · Technology

I’m cur­rent­ly work­ing on a mobile app made in React Native and I had to make a bot­tom sheet that slides up when a user press­es a but­ton.

These things can be pret­ty tricky in React Native, and you have to make sure it works on both Android and iOS (if you’re tar­get­ing both plat­forms that is).

I’ve made an approach that works pret­ty well on both plat­forms, with­out using React Native’s Modal com­po­nent which can be bug­gy some­times (from my own expe­ri­ence), just by using native Views and React Native’s Ani­mat­ed API.

A bot­tom sheet made in React Native

This is the final result. The card slides from the bot­tom when the user clicks a but­ton to show them more options. In my com­po­nent, you can have what­ev­er child com­po­nents you want inside the card con­tents, be it a list of options (like my exam­ple), a mes­sage with a but­ton to slide the card back down, etc.

Com­po­nent depen­den­cies that have been used:

  • Reac­t’s useRef hook. This hook allows you to (among oth­er stuff) define vari­ables inside a com­po­nent and have the val­ue be per­sis­tent through­out the com­po­nen­t’s life­cy­cle.
  • Reac­t’s useLayoutEffect hook to han­dle the ani­ma­tion side effects.
  • Reac­t’s useState hook to store state for point­erEvents. Point­erEvents define when a View can be clicked on (when “auto”) or clicked through (when “none”) on views under it.
  • React Native’s View.
  • React Native’s ImageBackground.
  • React Native’s Animated API, with useNativeDriver: true to ensure smooth ani­ma­tions on the native UI thread.
  • React Native’s TouchableWithoutFeedback for the back­ground. If the user clicks on the back­ground, the card will auto­mat­i­cal­ly close.
import React, { useRef, useLayoutEffect, useState } from 'react';
import {
  View,
  ImageBackground,
  Animated,
  TouchableWithoutFeedback,
} from 'react-native';

We start off by defin­ing the func­tion­al React com­po­nent and export­ing it. There are mul­ti­ple ways to do so, this is how I did it:

const BottomCard = ({
  show = false,
  children,
  style = {},
  ...propsSansStyle
}) => {};

export default BottomCard;

Let me explain the com­po­nen­t’s prop­er­ties:

  • show (true/false): Trig­gers the ani­ma­tion to show/hide the card and its over­lay.
  • chil­dren: The chil­dren views that will be put inside the card.
  • style: Just a sen­si­ble default for styling.
  • …propsSansStyle: All oth­er props that are passed will be added direct­ly onto the com­po­nen­t’s con­tain­er to allow more flex­i­bil­i­ty.

Inside of the com­po­nent, we’ll start by declar­ing a few vari­ables:

const layoutHeight = useRef(0); // Dynamic card height, initialized as zero
const translateYValue = useRef(new Animated.Value(0)); // Animated value for Y-axis translation
const opacityValue = useRef(new Animated.Value(0)); // Animated value for component opacity
const transform = [{
    translateY: translateYValue.current.interpolate({
        inputRange: [0, 1],
        outputRange: [layoutHeight.current, 0],
    })
}]; // Transform for Y-axis translation, interpolating from Y=cardLayoutHeight to Y=0 (slide-up animation)
const [pointerEvents, setPointerEvents] = useState('auto'); // State for pointerEvents

Now we need to declare the required func­tions. First we need a func­tion to set the layoutHeight val­ue once it’s loaded. Remem­ber that layoutHeight tells us the card’s height after resiz­ing it for its chil­dren views. We need this to know what the Y off­set will be to do the slid­ing-up ani­ma­tion.

  const onLayout = ({ nativeEvent }) => 
     (layoutHeight.current = nativeEvent.layout.height)
// To set the value of layoutHeight (the useRef variable we defined earlier) you use its "current" property. That's just how it works.

Next, the ani­ma­tion func­tions, start­ing with animateIn(), which, as its name sug­gests, fires the IN ani­ma­tion (the card slid­ing up):

const animateIn = () => {
  setPointerEvents('auto'); // Once we animate IN, we want the component to be clickable.
  Animated.parallel([
// We have 2 animations to run in parallel. The first one is the card sliding-up, the second one is the whole component's opacity, to make it fade in and out as the card is sliding.
    Animated.timing(translateYValue.current, {
       toValue: 1, // the Y value we're going to
       useNativeDriver: true,
          duration: 250,
        }),
    Animated.timing(opacityValue.current, {
        toValue: 1, // the target opacity
        useNativeDriver: true,
        duration: 250,
    }),
  ]).start();
}

And animateOut:

const animateOut = () => { // Pretty much the same as animateIn() but inverted
   Animated.parallel([
     Animated.timing(translateYValue.current, {
       toValue: 0,
       useNativeDriver: true,
       duration: 250,
     }),
     Animated.timing(opacityValue.current, {
       toValue: 0,
       useNativeDriver: true,
       duration: 250,
     }),
   ]).start(() => setPointerEvents('none')); // start() admits a callback to execute stuff when the animation ends, so we'll just make the component click-through then
}

Final­ly, we need to useLayoutEffect, to define when the ani­ma­tions need to be trig­gered.

// useLayoutEffect ensures this is run right after we obtain the layoutHeight value (we'll see how in a moment).
useLayoutEffect(() => {
  if (show) { // If the show property is true, this means we want to show the card, so run the animateIn() function.
    animateIn();
  } else {
// Else, we want to close it.
    animateOut();
  }
}, [show]); // useEffect and useLayoutEffect allow defining dependencies in an array as the second parameter. This means that whenever show (which is one of the component's properties, remember?) updates, this effect will be executed.

Once we have these out of the way, we can now define the com­po­nen­t’s JSX:

return (
 <View 
    style={{ ...containerStyle, ...style }} 
    pointerEvents={pointerEvents}>
   <Animated.View 
     style={opacityContainerStyle(opacityValue.current)}>
     <!-- 
          Wrap everything in ImageBackground, which 
          is a gradient image, to achieve the "modal effect" 
     -->
     <ImageBackground
       source={require('../assets/img/gradient.png')}
       style={overlayStyle}>
       <React.Fragment>
       <!-- 
            An empty, touchable View with width and height 
            of 100%, which represents the clickable background 
            that will (maybe) trigger animateOut() 
        -->
         <TouchableWithoutFeedback 
            onPress={propsSansStyle.onExit}>
           <View style={escapeHatchStyle} />
         </TouchableWithoutFeedback>
         <!-- 
             This is the actual card view, note that its 
             Animated.View instead of just View 
          -->
         <Animated.View
           onLayout={onLayout}
           style={{ ...cardStyle, transform }}>
           {children}
         </Animated.View>
       </React.Fragment>
     </ImageBackground>
   </Animated.View>
 </View>
);

And just to spice things up, add styling to the card:

const cardStyle = {
    position: 'absolute',
    left: 0,
    bottom: 0,
    backgroundColor: '#fff',
    zIndex: 5,
    width: '100%',
    borderTopRightRadius: 22,
    borderTopLeftRadius: 22,
  },
  overlayStyle = {
    position: 'absolute',
    zIndex: 4,
    flex: 1,
    width: '100%',
    height: '100%',
  },
  /** 
   * opacityContainerStyle needs to be a function of 
   * opacityValue.current in order to animate the components 
   * opacity. 
   **/
  opacityContainerStyle = opacity => ({
    height: '100%',
    width: '100%',
    opacity,
  }),
  containerStyle = {
    position: 'absolute',
    top: 0,
    left: 0,
    height: '100%',
    width: '100%',
    zIndex: 3,
  },
  escapeHatchStyle = {
    height: '100%',
    width: '100%',
  };

And that would be pret­ty much it! Here’s an exam­ple of how to use the com­po­nent:

import BottomCard from './BottomCard'
import React, { useState } from 'react';
import { View, Button } from 'react-native';

export default function myTestComponent() {
   const [
      bottomCardVisibility, 
      setBottomCardVisibility
   ] = useState(false);
   return (
       <View>
          <BottomCard 
             show={bottomCardVisibility} 
             onExit={() => setBottomCardVisibility(false)}>
             <Text style={{textAlign:'center'}}>
                This is the card content
             </Text>
          </BottomCard>
          <Button 
             title="Show card" 
             onPress={() => setBottomCardVisibility(true)} 
          />
       </View>
    );
};

Results should be some­thing sim­i­lar to this:

A bot­tom card slid­ing up and down… up… and down…

And there you have it! 😁 A smooth slid­ing bot­tom card/sheet for all your atten­tion-grab­bing needs.


– Kedi