DEV Community

Cover image for Implementing a (swipe-up) bottom drawer with animations in React Native | No external libraries
Johanna
Johanna

Posted on • Edited on

Implementing a (swipe-up) bottom drawer with animations in React Native | No external libraries

Ever wondered (or struggling with) how to create a bottom drawer, a popular feature in apps such as Airbnb? This post is here to help you! The drawer can be used for navigation between screens. It is also a handy element to display information as an overlay over maps or other screens.

In this post, we will use React Native's PanResponder to handle the swipe gestures.

The drawer states

Firstly, we will create an enum of the various stages/states of the drawer. In this example, there are three - closed, peek (open about 1/3 of screen height) and open.

const { height } = Dimensions.get('window');
export enum DrawerState {
  Open = height - 230,
  Peek = 230,
  Closed = 0,
}
Enter fullscreen mode Exit fullscreen mode

Creating the BottomDrawer component

The Animated View

The next step is to create a BottomDrawer component and add an Animated.View element as show below. The Animated.View is the ReactNative component that we will transform into the bottom drawer. In this example, I would like the view when closed to be still visible hence, setting the bottom to -height + 30. Feel free to play around with the styling!

const BottomDrawer: React.FunctionComponent<any> = () => {

  const { height } = Dimensions.get('window');

  return (
    <Animated.View
      style={[
        {
          width: '100%',
          height: height,
          backgroundColor: '#fff',
          borderRadius: 25,
          position: 'absolute',
          bottom: -height + 30,
        },
      ]}
    </Animated.View>
  );
};

export default BottomDrawer;

Enter fullscreen mode Exit fullscreen mode

The horizontal line

Create the styling for the horizontal line. This was implemented using styled-components but of course, it could also be done using styleSheets.

export const HorizontalLine = styled(View)`
  margin: 25px 0px 15px 0px;
  height: 1px;
  width: 100%;
  background-color: #D3D3D3
`;
Enter fullscreen mode Exit fullscreen mode

Import the HorizontalLine as a child of the Animated.View.

Handling gestures

We proceed by extending the BottomDrawer by adding the PanHandlers to handle the gestures. Most of the logic will be focused on the 'Y' axis/coordinate, as swiping up and down are vertical gestures. The BottomDrawer is a function component, therefore it uses the useRef hook to link the the animated values and pan handler. Why this hook? Read more about it on the react website.

The PanHandlers

const BottomDrawer: React.FunctionComponent<any> = () => {
  const { height } = Dimensions.get('window');
  /* Declare initial value of y. In this case, we want it to be closed when the component is closed */
  const y = React.useRef(new Animated.Value(DrawerState.Closed)).current;
  /* Declare another variable to keep track of the state. We need a separate variable for this because y will also change whilst the user is in the process of moving the drawer up or down */
  const state = React.useRef(new Animated.Value(DrawerState.Closed)).current;
  const margin = 0.05 * height;
  const movementValue = (moveY: number) => height - moveY;

  /* This event is triggered when the animated view is moving. We want the user to be able to drag/swipe up or down and the drawer should move simultaneously. */
  const onPanResponderMove = (
    _: GestureResponderEvent,
    { moveY }: PanResponderGestureState,
  ) => {
    const val = movementValue(moveY);
    animateMove(y, val);
  };

/* Here is where we snap the drawer to the desired state - open, peek or closed */
  const onPanResponderRelease = (
    _: GestureResponderEvent,
    { moveY }: PanResponderGestureState,
  ) => {
    const valueToMove = movementValue(moveY);
    const nextState = getNextState(state._value, valueToMove, margin);
    state.setValue(nextState);
    animateMove(y, nextState);
  };

/* This determines if the responder should do something. In this scenario, it is set to true when the distance moved by Y is greater than or equal to 10, or lesser than or equal to -10. */
  const onMoveShouldSetPanResponder = (
    _: GestureResponderEvent,
    { dy }: PanResponderGestureState,
  ) => Math.abs(dy) >= 10;

/* Here we're creating a panResponder object and assigning th event handlers to it. */
  const panResponder = useRef(
    PanResponder.create({
      onMoveShouldSetPanResponder,
      onStartShouldSetPanResponderCapture: onMoveShouldSetPanResponder,
      onPanResponderMove,
      onPanResponderRelease,
    }),
  ).current;

  return (
    <Animated.View
      style={[
        {
          width: '100%',
          height: height,
          backgroundColor: '#fff',
          borderRadius: 25,
          position: 'absolute',
          bottom: -height + 30,
          /* Refers to y variable which changes as the user performs a gesture */
          transform: [{ translateY: y }],
        },
      ]}
      /* Refers to the PanResponder created above */
      {...panResponder.panHandlers}>
      <HorizontalLine />
    </Animated.View>
  );
};

export default BottomDrawer;
Enter fullscreen mode Exit fullscreen mode

The helpers

We will implement the logic that handles what the next state will be : getNextState and another function that will handle the animation of the y coordinate : animateMove.

export const animateMove = (
  y: Animated.Value,
  toValue: number | Animated.Value,
  callback?: any,
) => {
  Animated.spring(y, {
    toValue: -toValue,
    tension: 20,
    useNativeDriver: true,
  }).start((finished) => {
/* Optional: But the purpose is to call this after the the animation has finished. Eg. Fire an event that will be listened to by the parent component */
    finished && callback && callback();
  });
};

export const getNextState = (
  currentState: DrawerState,
  val: number,
  margin: number,
): DrawerState => {
  switch (currentState) {
    case DrawerState.Peek:
      return val >= currentState + margin
        ? DrawerState.Open
        : val <= DrawerState.Peek - margin
        ? DrawerState.Closed
        : DrawerState.Peek;
    case DrawerState.Open:
      return val >= currentState
        ? DrawerState.Open
        : val <= DrawerState.Peek
        ? DrawerState.Closed
        : DrawerState.Peek;
    case DrawerState.Closed:
      return val >= currentState + margin
        ? val <= DrawerState.Peek + margin
          ? DrawerState.Peek
          : DrawerState.Open
        : DrawerState.Closed;
    default:
      return currentState;
  }
};
Enter fullscreen mode Exit fullscreen mode

Bonus: Add children and onDrawerStateChanged event

We will extend the properties to emit an event when the state has changed and support the addition of children to the component from its parent component. The final implementation looks like this:

interface BottomDrawerProps {
  children?: React.ReactNode;
  onDrawerStateChange: (nextState: DrawerState) => void;
}

const BottomDrawer: React.FunctionComponent<BottomDrawerProps> = ({
  children,
  onDrawerStateChange,
}) => {
  const { height } = Dimensions.get('window');
  const y = React.useRef(new Animated.Value(DrawerState.Closed)).current;
  const state = React.useRef(new Animated.Value(DrawerState.Closed)).current;
  const margin = 0.05 * height;
  const movementValue = (moveY: number) => height - moveY;

  const onPanResponderMove = (
    _: GestureResponderEvent,
    { moveY }: PanResponderGestureState,
  ) => {
    const val = movementValue(moveY);
    animateMove(y, val);
  };

  const onPanResponderRelease = (
    _: GestureResponderEvent,
    { moveY }: PanResponderGestureState,
  ) => {
    const valueToMove = movementValue(moveY);
    const nextState = getNextState(state._value, valueToMove, margin);
    state.setValue(nextState);
    animateMove(y, nextState, onDrawerStateChange(nextState));
  };
  const onMoveShouldSetPanResponder = (
    _: GestureResponderEvent,
    { dy }: PanResponderGestureState,
  ) => Math.abs(dy) >= 10;

  const panResponder = useRef(
    PanResponder.create({
      onMoveShouldSetPanResponder,
      onStartShouldSetPanResponderCapture: onMoveShouldSetPanResponder,
      onPanResponderMove,
      onPanResponderRelease,
    }),
  ).current;

  return (
    <Animated.View
      style={[
        {
          width: '100%',
          height: height,
          backgroundColor: '#fff',
          borderRadius: 25,
          position: 'absolute',
          bottom: -height + 30,
          transform: [{ translateY: y }],
        },
      ]}
      {...panResponder.panHandlers}>
      <HorizontalLine />
      {children}
    </Animated.View>
  );
};

export default BottomDrawer;
Enter fullscreen mode Exit fullscreen mode

The BottomDrawer can now be added to another component.

<ParentComponent>
  <BottomDrawer onDrawerStateChange={doSomething}>
    <SomeChild/>
  </BottomDrawer>
</ParentComponent>
Enter fullscreen mode Exit fullscreen mode

Top comments (4)

Collapse
 
fanchenbao profile image
Fanchen Bao

Very helpful, as this is my first foray into the Animation black magic. However, I am more in favor of the workflow demoed in the document, especially the use of setOffset() and flattenOffset() to control the drawer animation while it is being dragged.

Collapse
 
cybercris profile image
Cristiano Soares

Good explanation, but as @fanchenbao commented the approach using setOffset() and flattenOffset() is much better and lass complicated. It would be good to link a snack code in your post.

My snack implementing a bottomDrawer: snack.expo.dev/@cybercris/panrespo...

Collapse
 
hemmingaskodan profile image
Hemming • Edited

Making it work in the App I'm making, my frustration was the animation when dragging. After a lot of head scratching, I found the solution to make the animation without it lagging. Inside animateMove function, set useNativeDriver to false.

Collapse
 
tomjohnhall profile image
Tom John Hall

Ace! This is great, thank you 🙏