Drawer

PreviousNext

A drawer component for React Native applications.

Docs
nativeuiui

Preview

Loading preview…
registry/drawer/drawer.tsx
import { cn } from "@/lib/utils";
import * as React from "react";
import {
  Animated,
  Dimensions,
  Easing,
  KeyboardAvoidingView,
  Modal,
  PanResponder,
  Platform,
  StyleSheet,
  Text,
  TouchableWithoutFeedback,
  View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";

// Animation config constants
const ANIMATION = {
  OPEN: {
    BACKDROP_DURATION: 180,
    SPRING_VELOCITY: 3,
    SPRING_TENSION: 120,
    SPRING_FRICTION: 22,
  },
  CLOSE: {
    SPRING_FRICTION: 26,
    SPRING_TENSION: 100,
    SPRING_VELOCITY: 0.5,
    BACKDROP_DURATION: 280,
    BACKDROP_DELAY: 100,
  },
  SNAP: {
    SPRING_TENSION: 120,
    SPRING_FRICTION: 22,
  },
};

// Drag behavior constants
const DRAG = {
  THRESHOLD: 5,
  CLOSE_DISTANCE: 100,
  VELOCITY_THRESHOLD: {
    UP: 0.3,
    DOWN: 0.5,
  },
  RESISTANCE: 0.1,
};

// Drawer sizes - Definition of preset snap points
const DRAWER_SIZES = {
  SMALL: [0.3, 0.5], // Small drawer that can be extended to 50%
  MEDIUM: [0.5, 0.8], // Medium drawer that can be extended to 80%
  LARGE: [0.6, 0.8, 0.95], // Large drawer with size options
  FULL: [0.8, 0.95], // Full screen with reduced option
};

const { height: SCREEN_HEIGHT } = Dimensions.get("window");

export type DrawerSize = "small" | "medium" | "large" | "full" | number[];

const resolveSnapPoints = (size: DrawerSize): number[] => {
  if (Array.isArray(size)) return size;

  switch (size) {
    case "small":
      return DRAWER_SIZES.SMALL;
    case "medium":
      return DRAWER_SIZES.MEDIUM;
    case "large":
      return DRAWER_SIZES.LARGE;
    case "full":
      return DRAWER_SIZES.FULL;
    default:
      return DRAWER_SIZES.MEDIUM;
  }
};

interface DrawerProps {
  open: boolean;
  onClose: () => void;
  children: React.ReactNode;
  title?: string;
  size?: DrawerSize;
  initialSnapIndex?: number;
  snapPoints?: number[];
  contentClassName?: string;
  avoidKeyboard?: boolean;
  closeOnBackdropPress?: boolean;
  disableBackHandler?: boolean;
}

interface DrawerContextValue {
  close: () => void;
  snapTo: (index: number) => void;
  currentSnapIndex: number;
  snapPoints: number[];
  isClosing: boolean;
  isAnimating: boolean;
  position: Animated.Value;
}

export const DrawerContext = React.createContext<DrawerContextValue>({
  close: () => { },
  snapTo: () => { },
  currentSnapIndex: 0,
  snapPoints: DRAWER_SIZES.MEDIUM,
  isClosing: false,
  isAnimating: false,
  position: new Animated.Value(0),
});

export const useDrawer = () => React.useContext(DrawerContext);

const Drawer = React.forwardRef<View, DrawerProps>(
  (
    {
      open,
      onClose,
      children,
      title,
      size = "medium",
      initialSnapIndex = 0,
      snapPoints: providedSnapPoints,
      contentClassName,
      avoidKeyboard = true,
      closeOnBackdropPress = true,
      disableBackHandler = false,
    },
    ref
  ) => {
    const [isVisible, setIsVisible] = React.useState(false);
    const snapPoints = React.useMemo(
      () => providedSnapPoints || resolveSnapPoints(size),
      [size, providedSnapPoints]
    );
    const snapPointsPixels = React.useMemo(
      () => snapPoints.map((point) => SCREEN_HEIGHT - SCREEN_HEIGHT * point),
      [snapPoints]
    );

    const activeSnapIndex = React.useRef(initialSnapIndex);
    const translateY = React.useRef(new Animated.Value(SCREEN_HEIGHT)).current;
    const backdropOpacity = React.useRef(new Animated.Value(0)).current;
    const isClosing = React.useRef(false);
    const isAnimating = React.useRef(false);
    const hasInitializedOpen = React.useRef(false);

    const animateOpen = React.useCallback(() => {
      if (isAnimating.current) {
        translateY.stopAnimation();
        backdropOpacity.stopAnimation();
      }

      isAnimating.current = true;
      translateY.setValue(SCREEN_HEIGHT);
      backdropOpacity.setValue(0);
      isClosing.current = false;

      Animated.timing(backdropOpacity, {
        toValue: 1,
        duration: ANIMATION.OPEN.BACKDROP_DURATION,
        useNativeDriver: true,
        easing: Easing.out(Easing.ease),
      }).start();

      Animated.spring(translateY, {
        toValue: snapPointsPixels[initialSnapIndex],
        useNativeDriver: true,
        velocity: ANIMATION.OPEN.SPRING_VELOCITY,
        tension: ANIMATION.OPEN.SPRING_TENSION,
        friction: ANIMATION.OPEN.SPRING_FRICTION,
      }).start(() => {
        isAnimating.current = false;
        activeSnapIndex.current = initialSnapIndex;
      });
    }, [backdropOpacity, translateY, snapPointsPixels, initialSnapIndex]);

    const animateClose = React.useCallback(() => {
      if (isClosing.current) return;

      isClosing.current = true;

      if (isAnimating.current) {
        translateY.stopAnimation();
        backdropOpacity.stopAnimation();
      }

      isAnimating.current = true;

      Animated.spring(translateY, {
        toValue: SCREEN_HEIGHT,
        useNativeDriver: true,
        friction: ANIMATION.CLOSE.SPRING_FRICTION,
        tension: ANIMATION.CLOSE.SPRING_TENSION,
        velocity: ANIMATION.CLOSE.SPRING_VELOCITY,
      }).start();

      Animated.timing(backdropOpacity, {
        toValue: 0,
        duration: ANIMATION.CLOSE.BACKDROP_DURATION,
        easing: Easing.out(Easing.ease),
        useNativeDriver: true,
        delay: ANIMATION.CLOSE.BACKDROP_DELAY,
      }).start(() => {
        requestAnimationFrame(() => {
          setIsVisible(false);
          isClosing.current = false;
          isAnimating.current = false;
          hasInitializedOpen.current = false;
          onClose();
        });
      });
    }, [backdropOpacity, translateY, onClose]);

    React.useEffect(() => {
      if (open && !isVisible) {
        setIsVisible(true);
        return;
      }

      if (
        open &&
        isVisible &&
        !hasInitializedOpen.current &&
        !isClosing.current
      ) {
        animateOpen();
        hasInitializedOpen.current = true;
        return;
      }

      if (!open && isVisible && !isClosing.current) {
        animateClose();
      }
    }, [open, isVisible, animateOpen, animateClose]);

    const handleBackdropPress = React.useCallback(() => {
      if (closeOnBackdropPress && !isClosing.current) {
        isClosing.current = true;
        isAnimating.current = true;

        Animated.spring(translateY, {
          toValue: SCREEN_HEIGHT,
          useNativeDriver: true,
          friction: ANIMATION.CLOSE.SPRING_FRICTION,
          tension: ANIMATION.CLOSE.SPRING_TENSION,
          velocity: ANIMATION.CLOSE.SPRING_VELOCITY,
        }).start();

        Animated.timing(backdropOpacity, {
          toValue: 0,
          duration: ANIMATION.CLOSE.BACKDROP_DURATION,
          easing: Easing.out(Easing.ease),
          useNativeDriver: true,
          delay: ANIMATION.CLOSE.BACKDROP_DELAY,
        }).start(() => {
          requestAnimationFrame(() => {
            setIsVisible(false);
            isClosing.current = false;
            isAnimating.current = false;
            hasInitializedOpen.current = false;
            onClose();
          });
        });
      }
    }, [backdropOpacity, translateY, onClose, closeOnBackdropPress]);

    const animateToSnapPoint = React.useCallback(
      (index: number, velocity = 0) => {
        if (
          index < 0 ||
          index >= snapPointsPixels.length ||
          isAnimating.current
        )
          return;

        isAnimating.current = true;
        activeSnapIndex.current = index;

        Animated.spring(translateY, {
          toValue: snapPointsPixels[index],
          useNativeDriver: true,
          velocity: velocity,
          tension: ANIMATION.SNAP.SPRING_TENSION,
          friction: ANIMATION.SNAP.SPRING_FRICTION,
        }).start(() => {
          isAnimating.current = false;
        });
      },
      [snapPointsPixels]
    );

    const getTargetSnapIndex = React.useCallback(
      (currentY: number, velocity: number, dragDirection: "up" | "down") => {
        const isDraggingDown = dragDirection === "down";

        if (
          activeSnapIndex.current === snapPointsPixels.length - 1 &&
          isDraggingDown
        ) {
          return snapPointsPixels.length - 2 >= 0
            ? snapPointsPixels.length - 2
            : 0;
        }

        if (
          activeSnapIndex.current === 1 &&
          isDraggingDown &&
          velocity > DRAG.VELOCITY_THRESHOLD.UP
        ) {
          return 0;
        }

        if (
          activeSnapIndex.current === 0 &&
          isDraggingDown &&
          velocity > DRAG.VELOCITY_THRESHOLD.DOWN
        ) {
          return -1;
        }

        if (currentY > snapPointsPixels[0] + DRAG.CLOSE_DISTANCE) {
          return -1;
        }

        if (dragDirection === "up" && velocity > DRAG.VELOCITY_THRESHOLD.UP) {
          const nextIndex = Math.min(
            activeSnapIndex.current + 1,
            snapPointsPixels.length - 1
          );
          return nextIndex;
        }

        let closestIndex = 0;
        let minDistance = Math.abs(currentY - snapPointsPixels[0]);

        for (let i = 1; i < snapPointsPixels.length; i++) {
          const distance = Math.abs(currentY - snapPointsPixels[i]);
          if (distance < minDistance) {
            minDistance = distance;
            closestIndex = i;
          }
        }

        return closestIndex;
      },
      [snapPointsPixels]
    );

    const panResponder = React.useMemo(() => {
      let startY = 0;
      const maxDragPoint = snapPointsPixels.length
        ? snapPointsPixels[snapPointsPixels.length - 1]
        : 0;

      return PanResponder.create({
        onStartShouldSetPanResponder: () =>
          !isClosing.current && !isAnimating.current,
        onMoveShouldSetPanResponder: (_, { dy }) =>
          !isClosing.current &&
          !isAnimating.current &&
          Math.abs(dy) > DRAG.THRESHOLD,

        onPanResponderGrant: (_, { y0 }) => {
          startY = y0;
          translateY.stopAnimation();
          isAnimating.current = false;
        },

        onPanResponderMove: (_, { dy }) => {
          if (isClosing.current) return;

          const currentSnapY = snapPointsPixels[activeSnapIndex.current];
          let newY = currentSnapY + dy;

          if (newY < maxDragPoint) {
            const overscroll = maxDragPoint - newY;
            const resistedOverscroll =
              -Math.log10(1 + overscroll * DRAG.RESISTANCE) * 10;
            newY = maxDragPoint + resistedOverscroll;
          }

          translateY.setValue(newY);
        },

        onPanResponderRelease: (_, { dy, vy }) => {
          if (isClosing.current) return;

          const dragDirection = dy > 0 ? "down" : "up";
          const currentY = snapPointsPixels[activeSnapIndex.current] + dy;
          const absVelocity = Math.abs(vy);

          const targetIndex = getTargetSnapIndex(
            currentY,
            absVelocity,
            dragDirection
          );

          if (targetIndex === -1) {
            animateClose();
          } else {
            animateToSnapPoint(targetIndex, vy);
          }
        },
      });
    }, [
      snapPointsPixels,
      animateClose,
      animateToSnapPoint,
      getTargetSnapIndex,
    ]);

    const contextValue = React.useMemo(
      () => ({
        close: animateClose,
        snapTo: animateToSnapPoint,
        currentSnapIndex: activeSnapIndex.current,
        snapPoints,
        isClosing: isClosing.current,
        isAnimating: isAnimating.current,
        position: translateY,
      }),
      [animateClose, animateToSnapPoint, snapPoints, translateY]
    );

    const renderContent = React.useCallback(
      () => (
        <View className="flex-1">
          <Animated.View
            style={[styles.backdrop, { opacity: backdropOpacity }]}
          >
            {closeOnBackdropPress && (
              <TouchableWithoutFeedback onPress={handleBackdropPress}>
                <View style={StyleSheet.absoluteFillObject} />
              </TouchableWithoutFeedback>
            )}
          </Animated.View>

          <Animated.View
            style={[styles.drawerContainer, { transform: [{ translateY }] }]}
            className={cn(
              "absolute bottom-0 left-0 right-0 bg-popover rounded-t-xl overflow-hidden",
              Platform.OS === "ios" ? "ios:shadow-xl" : "android:elevation-24",
              contentClassName
            )}
          >

            <View {...panResponder.panHandlers}>
              <View className="w-full items-center py-2">
                <View className="w-10 h-1 rounded-full bg-muted-foreground/30" />
              </View>

              {title && (
                <View className="px-4 pt-1 pb-3 border-b border-border">
                  <Text className="text-xl font-medium text-center text-foreground">
                    {title}
                  </Text>
                </View>
              )}
            </View>

            <SafeAreaView className="flex-1" edges={["bottom"]}>
              <View ref={ref} className="flex-1">
                {children}
              </View>
            </SafeAreaView>
          </Animated.View>
        </View>
      ),
      [
        animateClose,
        backdropOpacity,
        closeOnBackdropPress,
        contentClassName,
        panResponder.panHandlers,
        title,
        translateY,
        children,
        ref,
      ]
    );

    if (!isVisible) return null;

    return (
      <DrawerContext.Provider value={contextValue}>
        <Modal
          visible={isVisible}
          transparent
          animationType="none"
          statusBarTranslucent
          onRequestClose={disableBackHandler ? undefined : animateClose}
        >
          {avoidKeyboard && Platform.OS === "ios" ? (
            <KeyboardAvoidingView
              behavior="padding"
              style={{ flex: 1 }}
              keyboardVerticalOffset={10}
            >
              {renderContent()}
            </KeyboardAvoidingView>
          ) : (
            renderContent()
          )}
        </Modal>
      </DrawerContext.Provider>
    );
  }
);

const styles = StyleSheet.create({
  backdrop: {
    ...StyleSheet.absoluteFillObject,
    backgroundColor: "rgba(0, 0, 0, 0.4)",
  },
  drawerContainer: {
    height: SCREEN_HEIGHT,
    shadowColor: "#000",
    shadowOffset: { width: 0, height: -3 },
    shadowOpacity: 0.15,
    shadowRadius: 8,
    elevation: 24,
  },
});

Drawer.displayName = "Drawer";

export { Drawer };

Installation

npx shadcn@latest add @nativeui/drawer

Usage

import { Drawer } from "@/components/ui/drawer"
<Drawer />