/**
* Drawer — a panel that comes in from an edge of the screen and covers the app
* until it is dismissed.
*
* ```tsx
*
*
*
*
*
*
*
*
*
*
*
*
*
*
*
* ```
*
* ## Why the sides are `start` and `end`
*
* A drawer is the one overlay whose whole identity is the edge it belongs to,
* and in a right-to-left app "the navigation edge" is the right one. Naming the
* sides `left` and `right` would bake a reading direction into the API and
* force every caller in an RTL app to invert it themselves, so the sides are
* logical: `start` is the edge text begins at, `end` the edge it runs toward.
* Both follow the enclosing ``, and `top` and `bottom` mean what
* they say because the vertical axis does not mirror.
*
* Yoga mirrors the panel's own position, because that is laid out from `start-0`
* / `end-0`. It does not mirror the drag, which is measured in raw pixels — so
* the gesture consults the direction and negates itself, which is what keeps a
* swipe *outward* dismissing in both directions rather than only one.
*
* ## Why it is not a BottomSheet with a different edge
*
* A sheet is sized by its content and dragged along the axis its scroller runs
* on, which is why so much of it is about sharing one drag between the two. A
* side drawer is sized by the screen and dragged across its scroller's axis, so
* the two gestures never compete: the cross-axis drag simply fails and the list
* keeps it. That difference is the reason this is a separate component and not
* a prop on the sheet.
*/
import {
cloneElement,
createContext,
isValidElement,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactElement,
type ReactNode,
} from 'react';
import {
Pressable,
ScrollView,
useWindowDimensions,
View,
type ScrollViewProps,
type ViewProps,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
Easing,
SlideInDown,
SlideInLeft,
SlideInRight,
SlideInUp,
runOnJS,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { tv } from 'tailwind-variants';
import { useCSSVariable } from 'uniwind';
import { XIcon } from '../../icons';
import { ModalPortal } from '../../primitives/portal';
import { Scrim } from '../../primitives/scrim';
import { Text, textChildren } from '../../primitives/text';
import { useBackHandler } from '../../hooks/use-back-handler';
import {
DirectionContext,
useDirection,
useDirectionSign,
} from '../../hooks/use-direction';
import { cn } from '../../utils/cn';
const SPRING = { damping: 24, stiffness: 300, mass: 0.7 } as const;
/** How long the panel takes to leave, whether by a drag or a press. */
const EXIT_DURATION = 200;
/** How far a drag has to travel outward before releasing it dismisses. */
const DISMISS_DISTANCE = 80;
/** A flick this fast dismisses regardless of how far it got. */
const DISMISS_VELOCITY = 650;
export type DrawerSide = 'start' | 'end' | 'top' | 'bottom';
export type DrawerSize = 'sm' | 'md' | 'lg' | 'full';
export type DrawerCloseSide = 'start' | 'end';
/**
* How much of the screen each size asks for, and the cap it is never allowed
* past on a wide screen.
*
* The cap is the whole point. A fraction alone reads correctly on a phone and
* absurdly on a tablet, where 78% of the width is a navigation list with a
* column of whitespace beside it. Capping turns the fraction into "no wider
* than it needs to be", which is what a drawer is.
*/
const WIDTH = {
sm: { fraction: 0.62, max: 280 },
md: { fraction: 0.78, max: 320 },
lg: { fraction: 0.88, max: 400 },
// Not 1: an edge of the app left showing is what says it is still there
// behind the drawer, and it is also the only thing left to tap to dismiss.
full: { fraction: 0.94, max: Infinity },
} as const;
/** The same idea on the vertical axis, where there is no sensible cap. */
const HEIGHT = {
sm: 0.3,
md: 0.45,
lg: 0.62,
full: 0.94,
} as const;
const panelVariants = tv({
base: 'absolute border-border bg-popover shadow-lg',
variants: {
side: {
// Three real edges each. The fourth is the screen edge the drawer is
// docked to, and drawing a border or a radius along it would be a line
// through the middle of nothing.
start: 'bottom-0 start-0 top-0 rounded-e-3xl border-e',
end: 'bottom-0 end-0 top-0 rounded-s-3xl border-s',
top: 'end-0 start-0 top-0 rounded-b-3xl border-b',
bottom: 'bottom-0 end-0 start-0 rounded-t-3xl border-t',
},
},
defaultVariants: {
side: 'start',
},
});
interface DrawerContextValue {
open: boolean;
setOpen: (open: boolean) => void;
}
const DrawerContext = createContext(null);
function useDrawer(component: string): DrawerContextValue {
const context = useContext(DrawerContext);
if (!context) {
throw new Error(`${component} must be used within a `);
}
return context;
}
/**
* What the panel knows and its parts need: which corner the close button took,
* so a header can leave it clear rather than wrap underneath it.
*/
interface DrawerSurfaceValue {
closeSide: DrawerCloseSide;
showClose: boolean;
}
const DrawerSurfaceContext = createContext(null);
export interface DrawerProps {
children: ReactNode;
/** Open state, when you want to own it. Pair with `onOpenChange`. */
open?: boolean;
/** Called with the next open state, whether the drawer or you caused it. */
onOpenChange?: (open: boolean) => void;
/** Open state to start at when you are not controlling it. */
defaultOpen?: boolean;
}
function DrawerRoot({
children,
open: controlledOpen,
onOpenChange,
defaultOpen = false,
}: DrawerProps) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
const controlled = controlledOpen !== undefined;
const open = controlled ? controlledOpen : uncontrolledOpen;
const setOpen = useCallback(
(next: boolean) => {
if (!controlled) setUncontrolledOpen(next);
onOpenChange?.(next);
},
[controlled, onOpenChange]
);
const context = useMemo(
() => ({ open, setOpen }),
[open, setOpen]
);
return (
{children}
);
}
export interface DrawerTriggerProps {
/** A single pressable element. Its own `onPress` still runs. */
children: ReactElement<{ onPress?: (...args: unknown[]) => void }>;
}
function DrawerTrigger({ children }: DrawerTriggerProps) {
const { setOpen } = useDrawer('Drawer.Trigger');
if (!isValidElement(children)) return children;
return cloneElement(children, {
onPress: (...args: unknown[]) => {
children.props.onPress?.(...args);
setOpen(true);
},
});
}
export interface DrawerCloseProps {
/** A single pressable element. Its own `onPress` still runs. */
children: ReactElement<{ onPress?: (...args: unknown[]) => void }>;
}
function DrawerClose({ children }: DrawerCloseProps) {
const { setOpen } = useDrawer('Drawer.Close');
if (!isValidElement(children)) return children;
return cloneElement(children, {
onPress: (...args: unknown[]) => {
children.props.onPress?.(...args);
setOpen(false);
},
});
}
export interface DrawerContentProps extends ViewProps {
className?: string;
/**
* Which edge the drawer is docked to. `start` and `end` are the edges text
* begins and ends at, so both follow the enclosing `` rather than
* pinning the drawer to a physical side.
*/
side?: DrawerSide;
/**
* How much of the screen the drawer takes — its width on `start` / `end`, its
* height on `top` / `bottom`. A horizontal drawer is also capped in points,
* so `md` is a 320-point navigation panel on a tablet rather than 78% of it.
*/
size?: DrawerSize;
/** Tap on the backdrop closes the drawer. Default true. */
dismissible?: boolean;
/**
* Drag the drawer back toward its edge to dismiss it. Default true. Turn it
* off for a drawer whose content wants the same axis — a horizontal
* scroller in a side drawer.
*/
swipeToDismiss?: boolean;
/** Show a close button in the drawer's inner top corner. Default true. */
showClose?: boolean;
/**
* Which top corner the close button takes. Logical, like `side`: `end` is the
* corner text runs toward, so it is the right one in a left-to-right app and
* the left one in a right-to-left one.
*
* Defaults to the corner *away* from the docked edge, so an `end` drawer's
* button does not sit against the screen edge the panel came out of. Set it
* when the drawer reads better with the button on the outer corner instead —
* a panel people close by reaching for the same corner every time.
*/
closeSide?: DrawerCloseSide;
/**
* Frost the screen behind the drawer instead of dimming it. Needs the
* optional `expo-blur`; without it this dims, rather than failing.
*
* Someone who has Reduce Transparency switched on gets an opaque
* backdrop instead, which is the whole point of the setting.
*/
blur?: boolean;
children?: ReactNode;
}
function DrawerContent({
className,
side = 'start',
size = 'md',
dismissible = true,
swipeToDismiss = true,
showClose = true,
closeSide,
blur = false,
children,
...props
}: DrawerContentProps) {
const { open, setOpen } = useDrawer('Drawer.Content');
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
const insets = useSafeAreaInsets();
const dir = useDirection();
const sign = useDirectionSign();
const closeTint = useCSSVariable('--color-muted-foreground');
// Memoised for the reason `Direction` memoises its own: this is the object
// that invalidates layout for the whole panel, and a fresh one every render
// would re-run Yoga over it for a value that almost never changes.
const directionStyle = useMemo(() => ({ direction: dir }) as const, [dir]);
const close = useCallback(() => setOpen(false), [setOpen]);
// An open drawer catches the Android back button, closing itself instead of
// popping the screen behind it.
useBackHandler(open, close);
const horizontal = side === 'start' || side === 'end';
/**
* How far the panel has to travel to be fully off-screen — the distance a
* dismissing drag animates out to, and the axis the drag is measured on.
*/
const extent = horizontal
? Math.min(screenWidth * WIDTH[size].fraction, WIDTH[size].max)
: screenHeight * HEIGHT[size];
/**
* Which way "outward" points in raw pixels.
*
* Yoga has already mirrored where the panel sits, but a transform is not
* laid out — it is applied to whatever Yoga decided — so this is the one
* place that has to know the reading direction and negate itself. A `start`
* drawer leaves toward negative X in a left-to-right app and positive X in a
* right-to-left one; `end` is the mirror of that.
*/
const outward = horizontal
? (side === 'start' ? -1 : 1) * sign
: side === 'top'
? -1
: 1;
/** Outward drag distance in points. Negative means dragged further in. */
const travel = useSharedValue(0);
/*
* Parked at zero on every open. A swipe-dismiss leaves the panel a full
* extent outward, and without this the next open would draw a lit backdrop
* over an app with the drawer still off-screen behind it — dimmed, blocking,
* and with nothing on it to close.
*/
useEffect(() => {
if (open) travel.value = 0;
}, [open, travel]);
const pan = useMemo(() => {
const gesture = Gesture.Pan()
.enabled(swipeToDismiss)
.onChange((event) => {
const delta = (horizontal ? event.changeX : event.changeY) * outward;
const next = travel.value + delta;
// Follow the finger outward; rubber-band the pull further in, which
// has nowhere to go.
travel.value = next > 0 ? next : next / 3;
})
.onEnd((event) => {
const velocity =
(horizontal ? event.velocityX : event.velocityY) * outward;
if (travel.value > DISMISS_DISTANCE || velocity > DISMISS_VELOCITY) {
// Straight to the exit, without finishing the slide first. The exit
// starts from wherever `travel` is and carries it the rest of the
// way, so driving it to `extent` here would be the same distance
// animated twice.
runOnJS(close)();
} else {
travel.value = withSpring(0, SPRING);
}
});
/*
* The drag has to give way to whatever the content is doing on the other
* axis. A side drawer holds a vertical list, so a mostly-vertical drag
* fails here and the list keeps it — which is why a side drawer needs none
* of the shared-gesture machinery a bottom sheet does.
*/
return horizontal
? gesture.activeOffsetX([-12, 12]).failOffsetY([-18, 18])
: gesture.activeOffsetY([-12, 12]).failOffsetX([-18, 18]);
}, [close, extent, horizontal, outward, swipeToDismiss, travel]);
const panelStyle = useAnimatedStyle(() =>
horizontal
? { transform: [{ translateX: travel.value * outward }] }
: { transform: [{ translateY: travel.value * outward }] }
);
/*
* Resolved once and shared, because the button's corner and the header's
* clearance are the same decision seen from two places. Letting each work it
* out from `side` is how they drift, and a header padded on the wrong side
* runs its title straight under the button.
*/
const closeCorner: DrawerCloseSide = closeSide ?? (side === 'end' ? 'start' : 'end');
const surface = useMemo(
() => ({ closeSide: closeCorner, showClose }),
[closeCorner, showClose]
);
/*
* The slide out is written by hand, because the panel is not always at rest
* when it starts.
*
* A preset animates from the view's *layout* position and applies its own
* transform, which overrides the one the drag was already using. So a
* swipe-dismiss played twice: the gesture carried the panel off-screen, and
* then the exit put it back at zero and slid it out again. Seeding the
* initial transform from `travel` makes the two one animation — the exit
* picks the panel up wherever the finger left it and carries it the rest of
* the way. A press on the close button or the backdrop leaves `travel` at
* zero, so that path is the same slide it always was.
*
* Above the early return, with every other hook. It reads nothing that is
* only known once the drawer is open, and a hook below that return is one
* that runs on an open drawer and not on a closed one.
*/
const exiting = useCallback(() => {
'worklet';
const shift = extent * outward;
const from = travel.value * outward;
const timing = { duration: EXIT_DURATION, easing: Easing.out(Easing.quad) };
return {
initialValues: {
transform: horizontal
? [{ translateX: from }, { translateY: 0 }]
: [{ translateX: 0 }, { translateY: from }],
},
animations: {
transform: horizontal
? [{ translateX: withTiming(shift, timing) }, { translateY: 0 }]
: [{ translateX: 0 }, { translateY: withTiming(shift, timing) }],
},
};
}, [extent, horizontal, outward, travel]);
if (!open) return null;
/*
* The slide has to be given a physical direction, since the animation
* presets are physical. This is the same mirroring Yoga did for the panel's
* position, applied by hand to the one thing Yoga does not own.
*/
const physical: 'left' | 'right' | 'top' | 'bottom' = horizontal
? (side === 'start') === (dir === 'ltr')
? 'left'
: 'right'
: side;
const entering = {
left: SlideInLeft,
right: SlideInRight,
top: SlideInUp,
bottom: SlideInDown,
}[physical];
/*
* The inset and the panel's own padding stack rather than compete.
*
* A drawer that reaches the top of the screen is drawn behind the status bar
* on purpose — a surface the app disappears under should not stop short of
* the edge — but the clock and the battery are drawn *in* that band, so the
* content clears the whole of it and then takes its padding on top. `Math.max`
* gave the two the same slot: on a 59-point inset it resolved to 59, leaving
* the header and the close button flush against the clock with nothing
* between them.
*
* `bottom` is the one side whose top edge is nowhere near the status bar, and
* `top` the one side ending in open screen rather than at the home indicator.
* Each takes plain padding on the edge it does not meet.
*/
const padding = {
paddingTop: side === 'bottom' ? 16 : insets.top + 16,
paddingBottom: side === 'top' ? 16 : Math.max(insets.bottom, 16),
};
return (
{/* Portal content mounts under PortalHost, outside this provider's
subtree — re-provide the context so Drawer.Close keeps working. */}
{/*
* The reading direction has to be put back on this layer, and in both
* of its forms.
*
* A portal escapes the `Direction` the drawer was written inside twice
* over. It escapes the *context*, because an element reads context from
* where it is rendered rather than from where it was written — the same
* reason `Drawer.Close` needs its provider back. And it escapes the
* *style*, because the flip is Yoga's: `direction` mirrors the view
* subtree it is set on, and this one now hangs off the portal host
* instead. Without the style a `start` drawer in a right-to-left app
* docks to the left while its animation comes from the right; without
* the provider its rows and its text stay left-to-right inside it.
*/}
{/* Scrim draws the backdrop and its own fade; the Pressable over
it is what closes the drawer, since the scrim takes no
touches. */}
{textChildren(children)}
{/*
* Last, and lifted above the content, so a header that spans
* the panel cannot bury it — in React Native a later sibling
* wins the touch, and a close button drawn first under a
* full-width title reads as a button that only works near its
* top edge.
*
* Offset by the panel's own top padding rather than pinned
* with `top-0`. An absolutely positioned child's containing
* block is the padding *edge*, so padding moves the content
* around it and leaves it where it was — `top-0` put the
* button at the top of the panel, which on the three sides
* that reach the top of the screen is behind the status bar.
* Taking the same number the padding does is what keeps it
* level with the first line of the header on every side.
*
* The corner follows the docked edge by default. An `end`
* drawer's own edge is the trailing one, so its button moves
* to the leading side rather than sitting against the screen
* edge the drawer came out of — and `closeSide` overrides
* that for a panel that wants the same corner every time.
*/}
{showClose ? (
) : null}
);
}
export interface DrawerHeaderProps extends ViewProps {
className?: string;
/** Heading for the drawer. Strings are wrapped; anything else is drawn as given. */
title?: ReactNode;
/** A line under the title, for what the drawer is for. */
description?: ReactNode;
children?: ReactNode;
}
function DrawerHeader({
className,
title,
description,
children,
...props
}: DrawerHeaderProps) {
const surface = useContext(DrawerSurfaceContext);
/*
* Padding on whichever side the close button took, so a long title wraps
* above it rather than running underneath it. Nothing to clear when the
* button was turned off, and a header used outside a panel keeps its plain
* padding rather than reserving a corner for a button that is not there.
*/
const clearance = !surface?.showClose
? undefined
: surface.closeSide === 'start'
? 'ps-14'
: 'pe-14';
return (
{typeof title === 'string' ? (
{title}
) : (
title
)}
{typeof description === 'string' ? (
{description}
) : (
description
)}
{textChildren(children)}
);
}
export interface DrawerBodyProps extends ScrollViewProps {
className?: string;
/**
* Scroll the body when it overflows. Pass `false` to lay the content out
* plainly instead — for a drawer whose content is known to fit, and for one
* that brings its own list, since a scroller nested inside this one leaves
* neither of them scrolling properly.
*/
scrollable?: boolean;
children?: ReactNode;
}
function DrawerBody({
className,
scrollable = true,
children,
contentContainerStyle,
...props
}: DrawerBodyProps) {
if (!scrollable) {
return (
{textChildren(children)}
);
}
return (
{textChildren(children)}
);
}
export interface DrawerFooterProps extends ViewProps {
className?: string;
children?: ReactNode;
}
function DrawerFooter({ className, children, ...props }: DrawerFooterProps) {
return (
{textChildren(children)}
);
}
export const Drawer = Object.assign(DrawerRoot, {
Trigger: DrawerTrigger,
Content: DrawerContent,
Header: DrawerHeader,
Body: DrawerBody,
Footer: DrawerFooter,
Close: DrawerClose,
});