Building a Draggable Sheet With React Native Reanimated 2

5 August 2022

5 Aug 2022

5/8/22

Jai Keery

5 MIN READ

5 MIN READ

Swipe on, swipe off — mastering screen real-estate with this overlay component most commonly seen as an adjunct to a maps screen. This step-by-step tutorial covers the fundamentals of creating a draggable sheet in React Native using Reanimated 2 and Gesture Handler for a polished look and feel.

What is a draggable sheet?

Similar to a drawer or modal, a sheet is an overlay component used to display additional information or functionality on top of a main component. The sliding aspect takes advantage of the familiar swipe gesture, allowing the user to quickly show or hide content.

Whereas drawers may be used for navigation, and modals for a single action, sheets are suitable for nesting a variety of components, to display content (e.g., text, images) or actions (e.g., buttons, switches). There is a flow involved in the swiping up of the sheet, and the continuation into scrollable content. This essentially affords you two screens in one.

You may have seen an example when using navigation or ride-share apps.

Why Reanimated 2?

Software Mansion’s React Native Reanimated makes use of JSI, which fundamentally changes how or, more accurately, where your javascript is being processed. Instead of using the React Native bridge, JSI allows for the synchronous calling of native methods, providing direct access to the UI thread. This significantly reduces the time it takes for the UI to respond to events, meaning animation logic will be invoked on the current frame. The result is smooth and responsive animations.

Unfortunately, there are a few drawbacks with this library. In particular, it isn’t possible to use remote debugging for your animation worklet logic, as it is being run synchronously via a separate JavaScript VM (see JSI).

First steps

Below are instructions for setting up the starter template I’ve created. It includes some plumbing to help us get coding.

  • git clone https://github.com/jai-adapptor/sliding-sheet

  • yarn

  • npx pod-install

  • yarn ios to run on iOS simulator

  • Next, in the App directory, create a new folder called components. In here we will create three files: Sheet.tsx, MainContent.tsx and ExampleComponent.tsx

Sheet.tsx will hold the logic and UI for the sheet itself, and MainContent.tsx will be our base screen over which the sheet will slide. ExampleComponent just provides some content to nest inside the sheet. We will add these newly created components to index.tsx

import Sheet from './components/Sheet';
import MainContent from './components/MainContent';
import ExampleComponent from './components/ExampleComponent';

function App() {
  return (
    <View style={{ flex: 1 }}>
      <MainContent />
      <Sheet>
        <ExampleComponent style={{ height: 60, marginBottom: 10 }} />
        <View style={{ flexDirection: 'row', marginBottom: 10 }}>
          <ExampleComponent style={{ flex: 1, height: 120, marginRight: 10 }} />
          <ExampleComponent style={{ flex: 1, height: 120 }} />
        </View>
      </Sheet>
    </View>
  );
}

Find the code for MainContent here, and ExampleComponent here, or feel free to create your own components to replace these placeholders.

Setting up the sheet

Now, for the main event, the sheet component. We will start by defining some types and getting screen dimensions for setting the component’s height.

// ... imports

interface SheetProps {
  minHeight?: number;
  maxHeight?: number;
  expandedHeight?: number;
}

type SheetPositions = 'minimised' | 'maximised' | 'expanded';

const window = Dimensions.get('window');
const screen = Dimensions.get('screen');

const NAV_HEIGHT = 48;

// component will go here

The props defined here are optional, and allow you to override the three default ‘snap’ positions of the sheet. We are also setting a constant for the height of a navigation bar which will include a close button to dismiss the sheet when in its maximised position.

const Sheet: React.FC<SheetProps> = (props) => {
  const [dimensions, setDimensions] = useState({ window, screen });

  useEffect(() => {
    // Watch for screen size changes and update the dimensions
    const subscription = Dimensions.addEventListener(
      'change',
      ({ window, screen }) => {
        setDimensions({ window, screen })

Here we are putting the screen’s dimensions inside a state, and using an event listener inside useEffect to update when the sizes change. We then define heights for the sheet’s three snap positions.

// Animated values
  const position = useSharedValue<SheetPositions>('minimised');
  const sheetHeight = useSharedValue(-minHeight);
  const navHeight = useSharedValue(0);

  const springConfig: WithSpringConfig = {
    damping: 50,
    mass: 0.3,
    stiffness: 120,
    overshootClamping: true,
    restSpeedThreshold: 0.3,
    restDisplacementThreshold: 0.3,
  };

  const DRAG_BUFFER = 40;

Reanimate it

Finally, we are getting into Reanimated territory. The useSharedValue hook is similar to a useState, but will be observed by Reanimated’s worklets, allowing the UI animations to respond to any changes to these values. The springConfig will determine how the spring animations look—feel free to play with these values until they look right to you. We have also added a DRAG_BUFFER constant to prevent the sheet from changing position until the user has dragged beyond a certain distance.

const onGestureEvent = useAnimatedGestureHandler({
  // Set the context value to the sheet's current height value
  onStart: (_ev, ctx: any) => {
    ctx.offsetY = sheetHeight.value;
  },
  // Update the sheet's height value based on the gesture
  onActive: (ev, ctx: any) => {
    sheetHeight.value = ctx.offsetY + ev.translationY;
  },
  // Snap the sheet to the correct position once the gesture ends
  onEnd: () => {
    // 'worklet' directive is required for animations to work based on shared values
    'worklet';
    // Snap to expanded position if the sheet is dragged up from minimised position
    // or dragged down from maximised position
    const shouldExpand =
      (position.value === 'maximised' &&
        -sheetHeight.value < maxHeight - DRAG_BUFFER) ||
      (position.value === 'minimised' &&
        -sheetHeight.value > minHeight + DRAG_BUFFER);
     // Snap to minimised position if the sheet is dragged down from expanded position
    const shouldMinimise =
      position.value === 'expanded' &&
      -sheetHeight.value < expandedHeight - DRAG_BUFFER;
     // Snap to maximised position if the sheet is dragged up from expanded position
    const shouldMaximise =
      position.value === 'expanded' &&
      -sheetHeight.value > expandedHeight + DRAG_BUFFER;
     // Update the sheet's position with spring animation
    if (shouldExpand) {
      navHeight.value = withSpring(0, springConfig);
      sheetHeight.value = withSpring(-expandedHeight, springConfig);
      position.value = 'expanded';
    } else if (shouldMaximise) {
      navHeight.value = withSpring(NAV_HEIGHT + 10, springConfig);
      sheetHeight.value = withSpring(-maxHeight, springConfig);
      position.value = 'maximised';
    } else if (shouldMinimise) {
      navHeight.value = withSpring(0, springConfig);
      sheetHeight.value = withSpring(-minHeight, springConfig);
      position.value = 'minimised';
    } else {
      sheetHeight.value = withSpring(
        position.value === 'expanded'
          ? -expandedHeight
          : position.value === 'maximised'
          ? -maxHeight
          : -minHeight,
        springConfig,
      );
    }
  },
});

Now we need to handle the pan gesture, and change the height of the sheet in response to the user’s swiping motion, as well as potentially change the snap position of the sheet. Reanimated provides the useAnimatedGestureHandler hook, which works in conjunction with react-native-gesture-handler to capture gesture events that we can then use to update our animation values.

The three methods we use here are onStart, onActive and onEnd. onStart is used to update the context, which can be thought of as state on the UI thread. This lets us keep track of the last place a gesture ended, and prevent a jump back to default values between consecutive gestures. onActive is a simple one-liner that sets the height of the sheet based on the gesture and its previous state. onEnd is used to snap the sheet to one of the predefined heights depending on which direction the gesture is made and whether or not the gesture distance exceeds our DRAG_BUFFER. Whenever we want to explicitly set the height of the sheet, we wrap the height value in withSpring using the config we defined earlier to animate.

const sheetHeightAnimatedStyle = useAnimatedStyle(() => ({
    // The 'worklet' directive is included with useAnimatedStyle hook by default
    height: -sheetHeight.value,
  }));

const sheetContentAnimatedStyle = useAnimatedStyle(() => ({
  paddingBottom: position.value === 'maximised' ? 180 : 0,
  paddingTop: position.value === 'maximised' ? 40 : 20,
  paddingHorizontal: 20,
}));

const sheetNavigationAnimatedStyle = useAnimatedStyle(() => ({
  height: navHeight.value,
  overflow: 'hidden',
}));

In order to pass the animatable styles to our components, we make use of the useAnimatedStyle hook, which will return a style object that is reactive to any of our shared values. This hook automatically provides the functionality of a worklet directive, meaning it will be executed on the UI thread.

Note: it is more performant to only include styles that are going to be animated in this hook. Additional styling should be passed to the component separately.

return (
    <View style={styles.container}>
      <PanGestureHandler onGestureEvent={onGestureEvent}>
        <Animated.View style={[sheetHeightAnimatedStyle, styles.sheet]}>
          <View style={styles.handleContainer}>
            <View style={styles.handle} />
          </View>
          <Animated.View style={sheetContentAnimatedStyle}>
            <Animated.View style={sheetNavigationAnimatedStyle}>
              <TouchableOpacity
                style={styles.closeButton}
                onPress={() => {
                  navHeight.value = withSpring(0, springConfig);
                  sheetHeight.value = withSpring(-expandedHeight, springConfig);
                  position.value = 'expanded';
                }}
              >
                <Text>{`❌`}</Text>
              </TouchableOpacity>
            </Animated.View>
            <SafeAreaView>
              <ScrollView>{props.children}</ScrollView>
            </SafeAreaView>
          </Animated.View>
        </Animated.View>
      </PanGestureHandler>
    </View>
  );

This is how the markup will look. Notice that we make use of Animated.View for any components that are taking in animated style props, and wrap the component in a PanGestureHandler to capture user gestures.

And here, finally, are our styles:

const styles = StyleSheet.create({
  // The sheet is positioned absolutely to sit at the bottom of the screen
  container: {
    position: 'absolute',
    bottom: 0,
    left: 0,
    right: 0,
  },
  sheet: {
    justifyContent: 'flex-start',
    backgroundColor: '#FFFFFF',
    // Round the top corners
    borderTopLeftRadius: 20,
    borderTopRightRadius: 20,
    minHeight: 80,
    // Add a shadow to the top of the sheet
    shadowColor: '#000',
    shadowOffset: {
      width: 0,
      height: -2,
    },
    shadowOpacity: 0.23,
    shadowRadius: 2.62,
    elevation: 4,
  },
  handleContainer: {
    alignItems: 'center',
    justifyContent: 'center',
    paddingTop: 10,
  },
  // Add a small handle component to indicate the sheet can be dragged
  handle: {
    width: '15%',
    height: 4,
    borderRadius: 8,
    backgroundColor: '#CCCCCC',
  },
  closeButton: {
    width: NAV_HEIGHT,
    height: NAV_HEIGHT,
    borderRadius: NAV_HEIGHT,
    alignItems: 'center',
    justifyContent: 'center',
    alignSelf: 'flex-start',
    marginBottom: 10,
  },
});

Outro

And that’s it for our sheet component. See the code for the component here.

For a full working demo, check out the demo branch.