/** * Swipe — a row that slides aside to reveal the things you can do to it. * * It is the one list interaction a phone has that a page does not: the actions * are not on screen taking up room, they are behind the row, and the gesture * that reveals them is the same one everywhere else in the OS. An inbox, a * task list, a settings screen — all of them want it, and all of them * otherwise end up with a trailing button too small to hit. * * ```tsx * * * } label="Delete" color="destructive" onPress={remove} /> * * * Invoice.pdf * * * ``` * * The sides are `start` and `end` rather than left and right, because the * gesture mirrors with the reading direction: in a right-to-left app the row * that opened toward the right has to open toward the left, and a caller * should not have to write that twice. Yoga mirrors where the panels sit; the * drag is measured in raw pixels and cannot be mirrored for us, so it reads * the direction and turns itself around. * * Everything that moves runs on the UI thread. A row being dragged does not * re-render — the only React work in a swipe is the callback at the end of it. * * Rows in a `Swipe.Group` close each other, so only one of them is ever open. * That is the behaviour of every list on the phone that has this gesture, and * a row cannot arrange it alone: it knows when it opens and has no way to hear * that a sibling did. */ import { Children, cloneElement, createContext, forwardRef, isValidElement, useCallback, useContext, useEffect, useImperativeHandle, useMemo, useRef, type ReactElement, type ReactNode, } from 'react'; import { View, type LayoutChangeEvent, type ViewProps } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { runOnJS, useAnimatedStyle, useSharedValue, withSpring, type SharedValue, } from 'react-native-reanimated'; import { useCSSVariable } from 'uniwind'; import { tv, type VariantProps } from 'tailwind-variants'; import { useDirectionSign } from '../../hooks/use-direction'; import { IconColorProvider } from '../../icons'; import { AnimatedPressable } from '../../primitives/animated-pressable'; import { Text } from '../../primitives/text'; import { cn } from '../../utils/cn'; import { selectionTick } from '../../utils/haptics'; const SPRING = { damping: 22, stiffness: 220, mass: 0.7 } as const; /** * Past its own width a panel has nothing left to reveal, so the row is let go * of gradually rather than stopped dead. 8 is where the rubber band reads as * resistance rather than as something broken. */ const OVERSHOOT_FRICTION = 8; /** How far past the panel a drag has to reach before a release fires an action. */ const FULL_SWIPE_RATIO = 1.6; /** Fraction of the panel a release has to clear for the row to stay open. */ const OPEN_RATIO = 0.5; /** How much of a fling to count as distance already travelled, in seconds. */ const VELOCITY_LOOKAHEAD = 0.12; type SwipeSide = 'start' | 'end'; /** The side a row is open on, or `null` while it is closed. */ export type SwipeOpenSide = SwipeSide | null; export interface SwipeHandle { /** Slide the row aside to reveal one side's actions. */ open: (side: SwipeSide) => void; /** Put the row back. */ close: () => void; } interface SwipeContextValue { close: () => void; } const SwipeContext = createContext(null); /* -------------------------------------------------------------------------- */ /* Group */ /* -------------------------------------------------------------------------- */ interface SwipeGroupContextValue { /** Take a slot in the group. Returns the function that gives it back. */ join: (row: SwipeGroupMember) => () => void; /** Say that this row has opened, so every other one can put itself away. */ opened: (row: SwipeGroupMember) => void; /** Shut every row in the group. */ closeAll: () => void; } interface SwipeGroupMember { close: () => void; } const SwipeGroupContext = createContext(null); export interface SwipeGroupProps extends ViewProps { className?: string; children?: ReactNode; /** * Close the other rows when one opens. On by default — that is the whole * reason to reach for a group. Turning it off keeps the container and the * `useSwipeGroup` handle while letting several rows stand open at once. */ exclusive?: boolean; } /** * Several rows that agree only one of them is open at a time. * * Every list of swipeable rows wants this, and every list has to be told: a * row knows when it opens but has no way to learn that a sibling did, so * without something above them a list ends up with three rows standing open * and a screen that reads as broken. The mail, message and reminder lists on * a phone all behave this way, and a list that does not is the odd one out. * * ```tsx * * {rows.map((row) => ( * * * * * … * * ))} * * ``` * * The rows register themselves rather than being found by inspecting children, * so a row nested inside anything at all still belongs — wrapped in an * `Item.Group`, produced by a `map`, rendered by a component of your own. The * alternative, walking the tree for `Swipe` elements, only ever works for the * one arrangement it was written against. * * Nothing here re-renders. The registry is a ref and closing a sibling writes * to that row's shared value, so opening a row costs the springs it starts and * no React work at all. */ const SwipeGroup = forwardRef( ({ className, children, exclusive = true, ...props }, ref) => { /* * A ref rather than state: membership changes as rows mount and unmount, * and rendering the whole list again because one row arrived would undo * the point of a component that never re-renders while it is dragged. */ const members = useRef(new Set()); const context = useMemo( () => ({ join: (row) => { members.current.add(row); return () => { members.current.delete(row); }; }, opened: (row) => { if (!exclusive) return; for (const other of members.current) { if (other !== row) other.close(); } }, closeAll: () => { for (const row of members.current) row.close(); }, }), [exclusive] ); return ( {children} ); } ); SwipeGroup.displayName = 'Swipe.Group'; /** * Shut every row in the enclosing `Swipe.Group`. * * The one thing a group knows that a single row cannot: a list that scrolls, * navigates away, or has just deleted the row that was open wants all of them * put back, and holding a ref to each row to do it by hand is bookkeeping the * group is already doing. * * ```tsx * const { closeAll } = useSwipeGroup(); * … * ``` * * Outside a group it is inert rather than an error, so a row that is sometimes * grouped and sometimes not does not need two versions of its parent. */ export function useSwipeGroup(): { closeAll: () => void } { const group = useContext(SwipeGroupContext); const closeAll = useCallback(() => group?.closeAll(), [group]); return { closeAll }; } /* -------------------------------------------------------------------------- */ /* Action */ /* -------------------------------------------------------------------------- */ /** * A tile is a filled block of colour with its content laid over it, which * settles both halves of how it is coloured: * * - The fill is the status colour at full strength, never a tint of it. A tile * only exists while the row is out of the way, so it has to be legible in the * moment it appears; a 6%-alpha wash of the row's own background is not a * tile at all, it is a hole with a glyph floating in it. * - The content is white, because the status colours are chosen to be carried * at full strength with white over them. The `-foreground` token of a status * is the *darker text* form of that hue, meant for a neutral surface — laid * over the fill it is the same hue twice and the label all but disappears. * * `default` and `primary` are the two that cannot take white. `primary` * inverts with the theme and owns a true on-primary token, so it uses it; * `default` is a mid grey in every theme, and takes the background colour, * which is the neutral furthest from it in whichever direction the theme runs. */ const actionVariants = tv({ slots: { root: 'h-full min-w-[80px] items-center justify-center gap-1.5 px-4', label: 'text-center text-xs font-semibold', }, variants: { color: { default: { root: 'bg-muted-foreground', label: 'text-background' }, primary: { root: 'bg-primary', label: 'text-primary-foreground' }, success: { root: 'bg-success', label: 'text-success-solid-foreground' }, warning: { root: 'bg-warning', label: 'text-warning-solid-foreground' }, info: { root: 'bg-info', label: 'text-info-solid-foreground' }, destructive: { root: 'bg-destructive', label: 'text-destructive-solid-foreground', }, }, }, defaultVariants: { color: 'default', }, }); export type SwipeActionColor = | 'default' | 'primary' | 'success' | 'warning' | 'info' | 'destructive'; /** * The fill a panel takes behind its tiles, so that dragging past the tiles * extends the outermost action's colour instead of opening a hole. */ const PANEL_FILL: Record = { default: 'bg-muted-foreground', primary: 'bg-primary', success: 'bg-success', warning: 'bg-warning', info: 'bg-info', destructive: 'bg-destructive', }; export interface SwipeActionProps extends Omit, VariantProps { className?: string; /** What the action does. Also what a screen reader is offered. */ label: string; /** * Drawn above the label and tinted to match it. Pass the glyph, not a colour * and not a size — a tile sizes it to read at a glance, since it is the part * of an action the eye reaches before the word underneath it. */ icon?: ReactNode; /** Run when the tile is tapped, or when a full swipe reaches it. */ onPress?: () => void; /** * Leave the row open after the action runs. Off by default: an action that * has already happened has nothing left to offer, and a row left standing * open is the most common way a swipe list ends up feeling stuck. */ keepOpen?: boolean; /** Extra classes for the label. */ labelClassName?: string; } /** * One tile behind the row. Sized by its own content down to a minimum wide * enough to hit, so a one-word action and a two-word one still line up. */ const SwipeAction = forwardRef( ( { className, labelClassName, color = 'default', label, icon, onPress, keepOpen = false, ...props }, ref ) => { const context = useContext(SwipeContext); const slots = actionVariants({ color }); const tint = useActionTint(color); return ( { if (!keepOpen) context?.close(); onPress?.(); }} className={slots.root({ className })} {...props} > {icon ? ( {sizeIcon(icon)} ) : null} {label} ); } ); SwipeAction.displayName = 'Swipe.Action'; /** * How big a glyph is drawn on a tile. The icons default to the 16 that suits * them inline in a row of text, which is too small here: a tile is 80 wide and * mostly empty, and the icon is the part of it read first — at 16 it looks * like a mistake rather than a target. */ const ICON_SIZE = 22; /** * The icon at tile size, unless the caller asked for one. Sizing it here rather * than asking every call site to is what keeps two tiles beside each other * matching, which is the whole reason a panel of them reads as a set. */ function sizeIcon(icon: ReactNode): ReactNode { if (!isValidElement<{ size?: number }>(icon)) return icon; if (icon.props.size !== undefined) return icon; return cloneElement(icon, { size: ICON_SIZE }); } /** * The colour a tile's glyph is drawn in — the same colour as its label, so the * two read as one thing. Resolved from the theme rather than written down as a * hex wherever the theme has an answer, since a hex stops being right the * moment the theme inverts; white is the exception, because a status fill is * the same saturated colour in every theme and white is what it carries. * * Both tokens are resolved on every render because a hook cannot be called for * one branch only. They are variable lookups, not work. */ function useActionTint(color: SwipeActionColor): string | undefined { const background = useCSSVariable('--color-background'); const primary = useCSSVariable('--color-primary-foreground'); if (color === 'default') return typeof background === 'string' ? background : undefined; if (color === 'primary') return typeof primary === 'string' ? primary : undefined; return '#ffffff'; } /* -------------------------------------------------------------------------- */ /* Panels */ /* -------------------------------------------------------------------------- */ export interface SwipePanelProps extends Omit { className?: string; children?: ReactNode; } /** * The panels are markers rather than renderers: the root lifts their children * out and lays them out itself, because it is the root that knows how wide the * gap behind the row currently is. Declaring them as elements is still how a * caller says which side an action belongs to, and it keeps both sides * readable in source rather than hidden inside two render props. */ const SwipeStart = forwardRef(() => null); SwipeStart.displayName = 'Swipe.Start'; const SwipeEnd = forwardRef(() => null); SwipeEnd.displayName = 'Swipe.End'; /* -------------------------------------------------------------------------- */ /* Reading the declared actions */ /* -------------------------------------------------------------------------- */ interface DeclaredAction { label: string; color: SwipeActionColor; onPress?: () => void; } /** * The actions a panel declared, in source order. Only direct `Swipe.Action` * children count — anything else in a panel is decoration, and calling a * stranger's `onPress` because it happened to have one would be worse than * ignoring it. */ function collectActions(node: ReactNode): DeclaredAction[] { const found: DeclaredAction[] = []; for (const child of Children.toArray(node)) { if (!isValidElement(child) || child.type !== SwipeAction) continue; const { label, color, onPress } = (child as ReactElement).props; found.push({ label, color: (color as SwipeActionColor) ?? 'default', onPress }); } return found; } /* -------------------------------------------------------------------------- */ /* Root */ /* -------------------------------------------------------------------------- */ export interface SwipeProps extends Omit { className?: string; /** * The row itself, plus a `Swipe.Start` and/or `Swipe.End` holding its * actions. Order does not matter — the panels are recognised by type. */ children?: ReactNode; /** * Let a drag carried well past the panel fire its outermost action on * release, without the tile ever being tapped. On by default, and the reason * the far end of a panel is the destructive slot by convention. */ fullSwipe?: boolean; /** Turn the gesture off and leave the row static. The tiles stay tappable. */ disabled?: boolean; /** Tick when a drag crosses the point at which letting go fires an action. */ haptics?: boolean; /** Told which side opened, or `null` when the row closed. */ onOpenChange?: (side: SwipeOpenSide) => void; /** Extra classes for the moving row. */ contentClassName?: string; } /** * A row that opens sideways. * * The panels sit behind the row rather than beside it, so nothing about the * layout changes when one opens: the row is the only thing that moves, and it * moves as a transform. Each panel stretches to exactly the gap the row has * left behind, which is what keeps an overshoot showing the outermost action's * colour rather than a hole through to the screen underneath. */ const SwipeRoot = forwardRef( ( { className, contentClassName, children, fullSwipe = true, disabled = false, haptics = false, onOpenChange, ...props }, ref ) => { const sign = useDirectionSign(); /** Logical offset of the row in points; positive reveals the start side. */ const offset = useSharedValue(0); /** * Where the offset stood when this drag began. A pan reports translation * from the touch down, so without it a row that is already open snaps shut * the instant a second drag starts. */ const origin = useSharedValue(0); /** Natural width of each panel's tiles, measured once and then only read. */ const startWidth = useSharedValue(0); const endWidth = useSharedValue(0); /** Whether a full swipe is currently armed, so the tick fires just once. */ const armed = useSharedValue(false); const { startNode, endNode, row } = useMemo(() => { let start: ReactNode = null; let end: ReactNode = null; const rest: ReactNode[] = []; for (const child of Children.toArray(children)) { if (isValidElement(child) && child.type === SwipeStart) { start = (child as ReactElement).props.children; } else if (isValidElement(child) && child.type === SwipeEnd) { end = (child as ReactElement).props.children; } else { rest.push(child); } } return { startNode: start, endNode: end, row: rest }; }, [children]); const startActions = useMemo(() => collectActions(startNode), [startNode]); const endActions = useMemo(() => collectActions(endNode), [endNode]); const hasStart = startNode != null; const hasEnd = endNode != null; /** * The side the row is open on, held in a ref rather than in state: nothing * here renders from it, and a row that re-rendered every time a drag * settled would be paying for the one thing this component exists to * avoid. */ const openSide = useRef(null); /** * Only a genuine change is reported. The gesture cannot know whether the * row was already closed when it settles it closed, so the comparison has * to happen here rather than at each call site. * * The comparison and the callback both have to sit outside React's state, * because `onOpenChange` is the caller's and will usually set state of its * own. Run from inside a state updater — which React is free to call while * rendering — that lands as a set during another component's render, and * React says so. */ const reportOpen = useCallback( (side: SwipeOpenSide) => { if (openSide.current === side) return; openSide.current = side; onOpenChange?.(side); }, [onOpenChange] ); const close = useCallback(() => { offset.value = withSpring(0, SPRING); reportOpen(null); }, [offset, reportOpen]); /* * Membership in a `Swipe.Group`, if there is one above this row. * * The registered member is a stable object rather than the `close` * function itself, because `close` changes identity whenever * `onOpenChange` does and the group would then be holding a stale entry * alongside a live one. The object never changes; what it closes is read * off a ref at the moment it is asked. */ const group = useContext(SwipeGroupContext); const closeRef = useRef(close); closeRef.current = close; const member = useRef({ close: () => closeRef.current() }).current; useEffect(() => group?.join(member), [group, member]); /** * Told when this row settles open, so the group can put its siblings away. * A row closing says nothing — the others are already closed, and telling * them so would be a round of work per settle for no change. */ const announce = useCallback( (side: SwipeOpenSide) => { reportOpen(side); if (side !== null) group?.opened(member); }, [reportOpen, group, member] ); useImperativeHandle( ref, () => ({ open: (side) => { const width = side === 'start' ? startWidth.value : endWidth.value; if (width === 0) return; offset.value = withSpring(side === 'start' ? width : -width, SPRING); announce(side); }, close, }), [offset, startWidth, endWidth, announce, close] ); /** * The action a full swipe fires: the one furthest from the row, since that * is the one the gesture travelled all the way to. The panels pack their * tiles against the row, so on the start side that is the first declared * and on the end side the last. */ const fire = useCallback( (side: SwipeSide) => { const action = side === 'start' ? startActions[0] : endActions[endActions.length - 1]; action?.onPress?.(); }, [startActions, endActions] ); const tick = useCallback(() => selectionTick(), []); /** * The drag has to lose to a scroll, or a list of swipeable rows could not * be scrolled without rows twitching open under the finger. * `activeOffsetX` makes it wait for clearly horizontal intent, and * `failOffsetY` hands the touch to the scroller outright the moment the * finger commits vertically. */ const pan = useMemo( () => Gesture.Pan() .enabled(!disabled && (hasStart || hasEnd)) .activeOffsetX([-12, 12]) .failOffsetY([-14, 14]) .onBegin(() => { origin.value = offset.value; armed.value = false; }) .onUpdate((event) => { // The gesture is raw pixels and the offset is logical, so a // right-to-left subtree runs all of this backwards. const next = origin.value + event.translationX * sign; const limit = next > 0 ? startWidth.value : endWidth.value; if (limit === 0) { // Nothing to reveal on this side: hold the row at rest rather // than letting it drift open over an empty panel. offset.value = 0; return; } const beyond = Math.abs(next) - limit; offset.value = beyond > 0 ? Math.sign(next) * (limit + beyond / OVERSHOOT_FRICTION) : next; if (!fullSwipe) return; const reached = Math.abs(offset.value) > limit * FULL_SWIPE_RATIO; if (reached !== armed.value) { armed.value = reached; if (reached && haptics) runOnJS(tick)(); } }) .onEnd((event) => { const current = offset.value; const toStart = current > 0; const side: SwipeSide = toStart ? 'start' : 'end'; const limit = toStart ? startWidth.value : endWidth.value; if (limit === 0) { offset.value = withSpring(0, SPRING); return; } if (fullSwipe && armed.value) { armed.value = false; offset.value = withSpring(0, SPRING); runOnJS(fire)(side); runOnJS(reportOpen)(null); return; } // Part of the fling counts as distance already covered, so a short // flick opens the row where a slow drag to the same point does not. const projected = Math.abs( current + event.velocityX * sign * VELOCITY_LOOKAHEAD ); if (projected > limit * OPEN_RATIO) { offset.value = withSpring(toStart ? limit : -limit, SPRING); runOnJS(announce)(side); } else { offset.value = withSpring(0, SPRING); runOnJS(reportOpen)(null); } }), [ disabled, hasStart, hasEnd, sign, fullSwipe, haptics, offset, origin, armed, startWidth, endWidth, fire, tick, reportOpen, announce, ] ); const contentStyle = useAnimatedStyle(() => ({ transform: [{ translateX: offset.value * sign }], })); /** * A panel is exactly the gap the row has left behind — no wider, so it can * never paint over the row, and no narrower, so an overshoot extends the * outermost action's colour instead of opening a hole onto the screen * underneath. At rest both are zero wide and neither is on screen at all. */ const startStyle = useAnimatedStyle(() => ({ width: Math.max(offset.value, 0), })); const endStyle = useAnimatedStyle(() => ({ width: Math.max(-offset.value, 0), })); /** * Measured from the tiles rather than from the panel around them. The panel * is as wide as the gesture has made it, which is the number being derived * here — reading it back off the panel would only ever report the animation * to itself, and the width would stay at the zero it starts from. */ const measure = (target: SharedValue) => (event: LayoutChangeEvent) => { target.value = event.nativeEvent.layout.width; }; /** * A swipe is invisible to a screen reader, so every action is offered as an * accessibility action on the row as well. That is the whole of the * alternative path: there is no other way to reach a tile that stays off * screen until a gesture nobody announced has been performed. */ const allActions = useMemo( () => [...startActions, ...endActions], [startActions, endActions] ); const a11yActions = useMemo( () => allActions.map(({ label }) => ({ name: label, label })), [allActions] ); const context = useMemo(() => ({ close }), [close]); return ( 0 ? a11yActions : undefined} onAccessibilityAction={(event) => { allActions .find(({ label }) => label === event.nativeEvent.actionName) ?.onPress?.(); }} className={cn('relative w-full overflow-hidden', className)} {...props} > {hasStart ? ( // Yoga puts the panel on the edge text begins at and mirrors it for // us; only the transform above had to be turned around. {/* * The tiles are absolute so they size to themselves rather than * to the panel clipping them, and pinned to the edge nearest the * row so they emerge from under it as it moves — rather than * sitting at the far edge with a growing gap in front of them. */} {startNode} ) : null} {hasEnd ? ( {endNode} ) : null} {row} ); } ); SwipeRoot.displayName = 'Swipe'; export const Swipe = Object.assign(SwipeRoot, { Group: SwipeGroup, Start: SwipeStart, End: SwipeEnd, Action: SwipeAction, });