/**
* Sortable — a list whose rows can be dragged into a different order.
*
* The one list interaction nothing else in the library covers. `Swipe` acts on
* a row, `Tree` opens one, `Table` sorts every row at once by a column — none
* of them let a person say *this one goes above that one*, which is how a
* playlist, a task list, a set of form fields or a run of dashboard tiles is
* actually arranged.
*
* ```tsx
* const [tasks, setTasks] = useState(TASKS);
*
* task.id)}
* onReorder={(_, { from, to }) => setTasks((t) => reorderItems(t, from, to))}
* gap={8}
* >
* {tasks.map((task) => (
*
* -
*
* {task.title}
*
*
*
*
* ))}
*
* ```
*
* ## Nothing is ever moved in the tree
*
* The rows stay exactly where they were laid out and are pushed around with
* transforms. A row's offset is the difference between where its slot sits in
* the order being dragged and where it sits in the order that was rendered —
* one subtraction, on the UI thread, per row per frame. Reordering the
* children instead would mean React reconciling the whole list on every slot
* the finger crosses, which is the one thing a drag cannot afford.
*
* It also means the component never owns the order. It reports where a row was
* dropped and the list is the caller's to rearrange, because the caller is the
* only one who knows what the ids stand for — a `value` that disagreed with
* the children would put rows in places their content had not moved to.
*
* ## Heights are measured, not assumed
*
* Every row reports its own height, so a list of rows of different sizes lands
* in the right slots. A fixed row height would be one number to get wrong in
* every list that has a two-line row in it, and the measurement costs one
* layout pass on mount.
*
* ## Where the drop is reported
*
* `onReorder` fires when the row has finished settling, not when the finger
* lifts. Between those two moments the row is animating into a slot that the
* layout does not know about yet; re-rendering the list in the middle of that
* would relayout every row underneath it and the settling row would jump. By
* the time the callback runs the rows are already where the new order puts
* them, so the re-render that follows changes nothing on screen.
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import { View, type LayoutChangeEvent, type ViewProps } from 'react-native';
import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler';
import Animated, {
measure,
runOnJS,
scrollTo,
useAnimatedRef,
useAnimatedStyle,
useDerivedValue,
useFrameCallback,
useReducedMotion,
useScrollViewOffset,
useSharedValue,
withSpring,
type AnimatedRef,
type SharedValue,
} from 'react-native-reanimated';
import { useCSSVariable } from 'uniwind';
import { GripVerticalIcon, IconColorProvider } from '../../icons';
import { cn } from '../../utils/cn';
import { impactKnock, selectionTick } from '../../utils/haptics';
import { moveWithPinned, stepWithPinned } from './reorder';
/**
* Rows getting out of the way of the one being carried. Quick, because they
* are answering a finger that has already moved — a neighbour that ambles into
* its new slot reads as the list struggling to keep up with the drag.
*
* Critically damped, and stiff. Both were wrong before: the spring overshot its
* slot and spent the rest of a third of a second coming back, so a row the
* finger had already passed was still visibly moving. A row getting out of the
* way has nothing to express by bouncing — it is not the thing being carried,
* and the fastest way to say "your place is free" is to be out of it.
*/
const DISPLACE = { damping: 28, stiffness: 400, mass: 0.5 } as const;
/**
* Settles a row into its slot. Stiffer and less bouncy than the library's
* overlay springs: a row that overshoots its slot reads as having landed in
* the wrong one and then corrected itself.
*
* The rest thresholds are loosened from the defaults on purpose. The drop is
* reported from this spring's completion, so the tolerance it finishes at is
* also how long the caller waits to hear about it — and the last hundredth of
* a point of travel is not something anybody can see.
*/
const LAND = {
damping: 26,
stiffness: 260,
mass: 0.7,
restDisplacementThreshold: 0.5,
restSpeedThreshold: 2,
} as const;
/**
* Coming loose, and settling back. Deliberately faster than `LAND`: the row
* has to be back at its own size by the time it arrives, or it finishes the
* drop full-sized and then shrinks, which reads as two separate movements.
*/
const LIFT = { damping: 20, stiffness: 400, mass: 0.5 } as const;
/**
* How much a lifted row grows. Enough to read as picked up over a full-width
* row, where a larger jump would push the row past the edges of the list it
* came out of.
*/
const LIFT_SCALE = 1.05;
/**
* How far the finger must travel on a handle before the drag takes over. In
* line with the rest of the library's pans: below about this, a list inside a
* scroller cannot be scrolled by a finger that happened to land on a grip.
*/
const HANDLE_SLOP = 10;
/** What starts a drag. */
export type SortableActivation = 'handle' | 'longPress';
/** Where a row ended up, alongside the order it produced. */
export interface SortableReorderDetails {
/** The row that was dragged. */
id: string;
/** Its index before the drag. */
from: number;
/** Its index after it. */
to: number;
}
/**
* The same move applied to a list of your own.
*
* `onReorder` hands back the new order of the ids, but a list is rarely a list
* of ids — it is a list of the things they name. This does the move on that
* list, and returns a new array rather than sorting in place, because the one
* that mutated would be the one React had already decided was unchanged.
*
* ```tsx
* onReorder={(_, { from, to }) => setTasks((tasks) => reorderItems(tasks, from, to))}
* ```
*
* Indices outside the list are returned unchanged rather than throwing: a
* reorder is not worth crashing a screen over, and a list that did not move is
* the honest result of a move that had nowhere to go.
*/
export function reorderItems(items: readonly T[], from: number, to: number): T[] {
if (from === to) return [...items];
if (from < 0 || from >= items.length) return [...items];
if (to < 0 || to >= items.length) return [...items];
const next = [...items];
const moved = next.splice(from, 1);
next.splice(to, 0, ...moved);
return next;
}
/* -------------------------------------------------------------------------- */
/* Slot maths */
/* -------------------------------------------------------------------------- */
/**
* How far down the list a row's slot begins, given an order and the measured
* heights — the sum of everything above it plus one gap per row above it.
*
* A worklet, and deliberately a loop rather than a cached prefix sum: the
* lists this component is for are the ones a person is willing to drag through
* by hand, and a cache would be another thing to invalidate every time a row
* changed height.
*/
function slotOffset(
order: readonly string[],
id: string,
heights: Record,
gap: number
): number {
'worklet';
let offset = 0;
for (let i = 0; i < order.length; i += 1) {
const at = order[i];
if (at === id) return offset;
offset += (at === undefined ? 0 : (heights[at] ?? 0)) + gap;
}
return offset;
}
/** The middle of the slot at `index`, in the same coordinates. */
function slotCenter(
order: readonly string[],
index: number,
heights: Record,
gap: number
): number {
'worklet';
let offset = 0;
for (let i = 0; i < index; i += 1) {
const at = order[i];
offset += (at === undefined ? 0 : (heights[at] ?? 0)) + gap;
}
const self = order[index];
return offset + (self === undefined ? 0 : (heights[self] ?? 0)) / 2;
}
/**
* Where the dragged row belongs now, given where its edges have reached.
*
* It walks outwards from the row's current slot and stops at the first
* neighbour it has not reached, rather than scanning the whole list for the
* nearest slot. The difference shows up with rows of unequal height: scanning
* can hand back a slot two places away that happens to be closer, which reads
* as the row skipping one.
*
* What counts as reaching a neighbour is the *leading edge* of the carried row
* against that neighbour's middle — its bottom edge going down, its top edge
* going up. Comparing middle against middle, as this used to, means the finger
* has to travel a whole row before anything happens, because a row's middle
* starts a whole row away from its neighbour's: the list sat still through the
* first row of every drag and then moved all at once. Leading edge against
* middle halves that, and it is also the more natural reading — the rows get
* out of the way once the row being carried is over them, not once it is past
* them.
*/
function targetIndex(
order: readonly string[],
current: number,
top: number,
height: number,
heights: Record,
gap: number
): number {
'worklet';
// Where the carried row's own slot begins, so the direction of travel is
// read from the row rather than from the sign of a gesture that may have
// changed its mind since.
const self = order[current];
const restingTop =
slotCenter(order, current, heights, gap) -
(self === undefined ? 0 : (heights[self] ?? 0)) / 2;
if (top < restingTop) {
let target = current;
for (let i = current - 1; i >= 0; i -= 1) {
if (top >= slotCenter(order, i, heights, gap)) break;
target = i;
}
return target;
}
const bottom = top + height;
let target = current;
for (let i = current + 1; i < order.length; i += 1) {
if (bottom <= slotCenter(order, i, heights, gap)) break;
target = i;
}
return target;
}
/* -------------------------------------------------------------------------- */
/* Context */
/* -------------------------------------------------------------------------- */
interface SortableContextValue {
/** The order being dragged. Diverges from `rendered` mid-drag, never after. */
order: SharedValue;
/** The order the children were laid out in — what the transforms subtract. */
rendered: SharedValue;
/** Measured row heights, keyed by id. */
heights: SharedValue>;
/** Which ids hold their slot. Read on the UI thread while a drag resolves. */
pinned: SharedValue>;
/**
* How far each row is from where it was laid out, keyed by id.
*
* Derived once per rearrangement rather than worked out by each row for
* itself. Every row's style worklet re-runs on every frame of a drag — it
* closes over the value the carried row is riding on — so a row summing the
* heights above it twice per frame made the list cost the square of its
* length to drag, which is felt exactly when a list is long enough to be
* worth reordering by hand. This is invalidated by the same shared values it
* is built from, so a row changing height still puts it right.
*/
offsets: SharedValue>;
/** The row under the finger, or `null`. Also the settling row, until it lands. */
activeId: SharedValue;
/** The active row's offset from where it was laid out. */
translate: SharedValue;
/**
* How far the active row has come loose: 0 sitting in the list, 1 carried.
* Driven from the gesture rather than derived from `activeId`, so the row
* starts settling back the moment the finger lifts instead of waiting for
* the drop to finish first.
*/
lift: SharedValue;
/**
* Which drag is in flight. Bumped on every lift and every drop, so a landing
* spring that is still running when the next drag begins can tell that the
* row is no longer its to put down.
*/
dragSeq: SharedValue;
/** The finger's position on the screen, for the scroller to read. */
fingerY: SharedValue;
/** The enclosing scroller's offset, or a constant 0 when there is none. */
scrollOffset: SharedValue;
/** What that offset was when the drag began. */
scrollAtStart: SharedValue;
gap: number;
disabled: boolean;
activation: SortableActivation;
longPressDelay: number;
haptics: boolean;
reducedMotion: boolean;
/** JS-side mirror of `activeId`, written twice a drag rather than per frame. */
activeItem: string | null;
/** Index of each id in the rendered order. */
indexOf: (id: string) => number;
measured: (id: string, height: number) => void;
/** Register or clear a row's hold on its slot. */
setPinned: (id: string, value: boolean) => void;
begin: (id: string) => void;
settled: (id: string) => void;
/** Move a row by whole slots — the path that is not a gesture. */
step: (id: string, delta: number) => void;
setAutoscroll: (active: boolean) => void;
}
const SortableRootContext = createContext(null);
function useSortableRoot(part: string): SortableContextValue {
const context = useContext(SortableRootContext);
if (!context) throw new Error(`${part} must be used inside .`);
return context;
}
interface SortableItemContextValue {
id: string;
index: number;
isActive: boolean;
disabled: boolean;
/** The pan, when it belongs to a handle rather than to the whole row. */
handleGesture: GestureType | null;
}
const SortableItemContext = createContext(null);
/**
* What the row being rendered knows about itself.
*
* For a handle of your own, or for a row that looks different while it is
* being carried — dimmed neighbours, a border, a shadow. `isActive` is a plain
* boolean and changes twice in a drag rather than sixty times a second: it is
* set when the row is lifted and cleared when it lands, and nothing between
* those two moments touches React at all.
*/
export function useSortableItem(): {
id: string;
index: number;
isActive: boolean;
disabled: boolean;
} {
const context = useContext(SortableItemContext);
if (!context) throw new Error('useSortableItem must be used inside .');
const { id, index, isActive, disabled } = context;
return { id, index, isActive, disabled };
}
/* -------------------------------------------------------------------------- */
/* Root */
/* -------------------------------------------------------------------------- */
export interface SortableProps extends Omit {
className?: string;
/**
* The ids of the rows, in the order they are rendered below. It is the
* caller's array rather than the component's, because only the caller knows
* what an id stands for — an order held here that disagreed with the
* children would put rows in places their content had not moved to.
*/
value: string[];
/**
* The rows, one `Sortable.Item` per id in `value` and in the same order.
* They may be wrapped in anything; each row registers itself.
*/
children?: ReactNode;
/**
* Told the new order once the dropped row has settled, and where it came
* from and went. Rearrange your own list from `details` — `reorderItems`
* does exactly this move.
*/
onReorder?: (order: string[], details: SortableReorderDetails) => void;
/**
* Space between rows, in points. A prop rather than a `gap` class because
* the drag has to know it: the slot a row lands in is measured, and a gap
* the component cannot read is a gap it drops rows into the middle of.
*/
gap?: number;
/**
* What lifts a row. `handle` is the default and the safer one — the rest of
* the row stays free to be pressed, and a list of rows with buttons on them
* still works. `longPress` gives the whole row to the drag.
*/
activation?: SortableActivation;
/** How long `longPress` activation waits, in milliseconds. */
longPressDelay?: number;
/**
* Knock when a row is lifted, tick as it passes each slot. On by default:
* a drag with no feedback under the finger is the interaction people give up
* on halfway through, unsure whether anything is happening.
*/
haptics?: boolean;
/** Turn every row's drag off and leave the list static. */
disabled?: boolean;
/**
* The scroller the list sits in, from `useAnimatedRef`. Given one, a drag
* carried to the top or bottom edge scrolls it, so a list longer than the
* screen can be reordered end to end. Without it a drag stops at the edge,
* which is correct for a list that fits.
*/
scrollRef?: AnimatedRef;
/** Points from the scroller's edge at which the scrolling begins. */
autoscrollThreshold?: number;
/** Points per frame at the very edge, tapering to nothing at the threshold. */
autoscrollSpeed?: number;
/** Told which row was lifted, the moment it is. */
onDragStart?: (id: string) => void;
/** Told when it lands, whether or not the order changed. */
onDragEnd?: (id: string) => void;
}
/**
* The list, and everything the rows share.
*
* It renders one plain `View` in a column and nothing else. The rows are the
* caller's, laid out by flexbox in the order they were written; the component
* only ever adds a transform to them.
*/
function SortableRoot({
className,
value,
children,
onReorder,
gap = 0,
activation = 'handle',
longPressDelay = 220,
haptics = true,
disabled = false,
scrollRef,
autoscrollThreshold = 72,
autoscrollSpeed = 8,
onDragStart,
onDragEnd,
...props
}: SortableProps) {
const order = useSharedValue(value);
const rendered = useSharedValue(value);
const heights = useSharedValue>({});
const pinned = useSharedValue>({});
const activeId = useSharedValue(null);
const translate = useSharedValue(0);
const lift = useSharedValue(0);
const dragSeq = useSharedValue(0);
const fingerY = useSharedValue(0);
const [activeItem, setActiveItem] = useState(null);
const reducedMotion = useReducedMotion();
/*
* A joined key rather than the array: `value` is almost always a fresh
* `.map()` on every render, so depending on it directly would reset the
* order on every keystroke elsewhere on the screen. A null separator
* cannot appear in an id that came from anywhere real.
*/
const key = value.join('\u0000');
/*
* The caller has applied the drop, so the rendered order is the new one and
* the transforms that were holding rows in their new slots are no longer
* needed — the layout puts them there now. Both orders are reset together,
* which is what makes the re-render after a drop invisible.
*
* During render, and not in an effect. An effect runs *after* the commit that
* moved the rows, so for the frame in between, the dropped row sat in its new
* slot still carrying the transform that had carried it there — drawn a whole
* row's height away from where it belongs, which is what the flash after a
* drop was. Written here it lands on the same commit as the reorder, and there
* is no frame in which the two disagree.
*
* Writing a shared value is not React state, so this is not a render with a
* side effect on the tree; the ref is only there to make it happen once per
* change of order rather than on every render of the screen around it.
*/
const applied = useRef(key);
if (applied.current !== key) {
applied.current = key;
order.value = value;
rendered.value = value;
translate.value = 0;
lift.value = 0;
}
const indices = useMemo(() => {
const map = new Map();
value.forEach((id, index) => map.set(id, index));
return map;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
const indexOf = useCallback((id: string) => indices.get(id) ?? -1, [indices]);
/*
* Both maps are accumulated in a ref and then published, rather than built by
* reading the shared value back and spreading it. Every row reports its
* layout in the same batch on mount, and a write to `.value` is not visible
* to the next read in that batch — so a read-modify-write there has all the
* rows spreading the same empty map and only the last one surviving. A list
* that knows one row's height puts every slot a gap apart, and the first
* drag drops the row at the end of the list.
*/
const measuredHeights = useRef>({});
const pinnedFlags = useRef>({});
const measured = useCallback(
(id: string, height: number) => {
if (measuredHeights.current[id] === height) return;
measuredHeights.current = { ...measuredHeights.current, [id]: height };
heights.value = measuredHeights.current;
},
[heights]
);
const setPinned = useCallback(
(id: string, next: boolean) => {
if (Boolean(pinnedFlags.current[id]) === next) return;
pinnedFlags.current = { ...pinnedFlags.current, [id]: next };
pinned.value = pinnedFlags.current;
},
[pinned]
);
/*
* Every row's distance from where it was laid out, in one pass.
*
* Two prefix sums — one over the order being dragged, one over the order the
* children are actually in — and the difference between them per id. Rebuilt
* when a swap changes `order`, when a drop resets both, or when a row reports
* a new height, and at no other time; a drag that is only moving the carried
* row does not touch it at all.
*/
const offsets = useDerivedValue>(() => {
const map = heights.value;
const target: Record = {};
const list = order.value;
let at = 0;
for (let i = 0; i < list.length; i += 1) {
const id = list[i];
if (id === undefined) continue;
target[id] = at;
at += (map[id] ?? 0) + gap;
}
const result: Record = {};
const laid = rendered.value;
at = 0;
for (let i = 0; i < laid.length; i += 1) {
const id = laid[i];
if (id === undefined) continue;
result[id] = (target[id] ?? at) - at;
at += (map[id] ?? 0) + gap;
}
return result;
}, [gap]);
/* ---------------------------------------------------------------------- */
/* Autoscroll */
/* ---------------------------------------------------------------------- */
/*
* A ref of its own so `useScrollViewOffset` always has one to hold. It is
* never attached to anything, and the hook is happy with a ref that resolves
* to nothing — which is exactly the case where there is no scrolling to do.
*/
const fallbackRef = useAnimatedRef();
const scrollOffset = useScrollViewOffset(scrollRef ?? fallbackRef);
/**
* Where the scroller stood when the drag began. The row follows the finger,
* and the finger is measured against the screen — so every point the list
* scrolls underneath it has to be added back, or a row would slide out from
* under the finger the moment the list started moving.
*/
const scrollAtStart = useSharedValue(0);
/*
* A frame callback rather than something driven by the pan, because the
* finger held still at the edge is exactly when the scrolling has to keep
* happening — and a pan that is not moving sends no updates.
*/
const autoscroll = useFrameCallback(() => {
'worklet';
if (!scrollRef || activeId.value === null) return;
const view = measure(scrollRef);
if (!view) return;
const top = view.pageY + autoscrollThreshold;
const bottom = view.pageY + view.height - autoscrollThreshold;
const y = fingerY.value;
let intensity = 0;
if (y < top) intensity = (y - top) / autoscrollThreshold;
else if (y > bottom) intensity = (y - bottom) / autoscrollThreshold;
if (intensity === 0) return;
const clamped = Math.max(-1, Math.min(1, intensity));
scrollTo(scrollRef, 0, scrollOffset.value + clamped * autoscrollSpeed, false);
}, false);
/*
* `setActive` is a JS-thread call, so the gesture reaches it through
* `runOnJS` rather than flipping a flag the callback would have to poll.
*/
const setAutoscroll = useCallback(
(active: boolean) => {
if (!scrollRef) return;
autoscroll.setActive(active);
},
[autoscroll, scrollRef]
);
/* ---------------------------------------------------------------------- */
/* Lifting and landing */
/* ---------------------------------------------------------------------- */
const begin = useCallback(
(id: string) => {
setActiveItem(id);
setAutoscroll(true);
if (haptics) impactKnock();
onDragStart?.(id);
},
[haptics, onDragStart, setAutoscroll]
);
/**
* The row has landed. Only now is the drop reported: until the spring
* finished, the row was in a slot the layout knew nothing about, and a
* re-render in the middle of that would have relaid out every row under it
* while one of them was still moving.
*/
const settled = useCallback(
(id: string) => {
setActiveItem(null);
setAutoscroll(false);
onDragEnd?.(id);
const next = order.value;
const from = rendered.value.indexOf(id);
const to = next.indexOf(id);
if (from === to || from < 0 || to < 0) return;
onReorder?.([...next], { id, from, to });
},
[onDragEnd, onReorder, order, rendered, setAutoscroll]
);
/**
* A move of whole slots, with no gesture behind it — the path a screen
* reader takes, and the one a keyboard would take if a phone had one. It
* reports the drop straight away: nothing is mid-flight, so there is no
* settling to wait for.
*/
const step = useCallback(
(id: string, delta: number) => {
const current = rendered.value.indexOf(id);
if (current < 0) return;
const next = stepWithPinned(
rendered.value,
rendered.value,
pinned.value,
id,
current,
delta
);
const to = next.indexOf(id);
if (to === current) return;
order.value = next;
if (haptics) selectionTick();
onReorder?.(next, { id, from: current, to });
},
[haptics, onReorder, order, pinned, rendered]
);
const context = useMemo(
() => ({
order,
rendered,
heights,
pinned,
offsets,
activeId,
translate,
lift,
dragSeq,
fingerY,
scrollOffset,
scrollAtStart,
gap,
disabled,
activation,
longPressDelay,
haptics,
reducedMotion,
activeItem,
indexOf,
measured,
setPinned,
begin,
settled,
step,
setAutoscroll,
}),
[
order,
rendered,
heights,
pinned,
offsets,
activeId,
translate,
lift,
dragSeq,
fingerY,
scrollOffset,
scrollAtStart,
gap,
disabled,
activation,
longPressDelay,
haptics,
reducedMotion,
activeItem,
indexOf,
measured,
setPinned,
begin,
settled,
step,
setAutoscroll,
]
);
return (
0 ? { gap } : undefined}
{...props}
>
{children}
);
}
SortableRoot.displayName = 'Sortable';
/* -------------------------------------------------------------------------- */
/* Item */
/* -------------------------------------------------------------------------- */
export interface SortableItemProps extends Omit {
className?: string;
/**
* What this row is, and the id that appears in `value` and in the order
* handed back. Stable across renders — an id derived from the index changes
* the moment the list is reordered, and the rows lose track of themselves.
*/
id: string;
children?: ReactNode;
/**
* Stop this row being picked up. The others still move past it, because a
* row that cannot be dragged is not the same as a row that cannot be
* displaced — that is what `pinned` is for, and conflating the two would mean
* silently refusing drops that look like they worked.
*/
disabled?: boolean;
/**
* Hold this row's place in the list. It cannot be picked up, and — unlike a
* `disabled` row — nothing else can take its slot either: the rows being
* dragged reorder among the places left over, and one carried past this row
* goes around it rather than through it.
*
* For the row that means something by being where it is. A header, a total, a
* step that has to come first.
*/
pinned?: boolean;
/**
* Extra classes for the row while it is being carried, applied last. A
* lifted row is given an opaque surface and a shadow so it is never drawn
* see-through over the rows it is passing; this is what overrides that.
*/
activeClassName?: string;
}
/**
* One row.
*
* It measures itself, carries its own transform, and holds the pan — which is
* either wrapped around the whole row or handed to a `Sortable.Handle` inside
* it, depending on what lifts a row in this list.
*/
function SortableItem({
className,
activeClassName,
id,
children,
disabled = false,
pinned = false,
...props
}: SortableItemProps) {
const root = useSortableRoot('Sortable.Item');
const {
order,
rendered,
heights,
pinned: pinnedIds,
offsets,
activeId,
translate,
lift,
dragSeq,
fingerY,
scrollOffset,
scrollAtStart,
gap,
activation,
longPressDelay,
haptics,
reducedMotion,
activeItem,
indexOf,
measured,
setPinned,
begin,
settled,
step,
} = root;
/** A row is undraggable if it, the whole list, or its own pin says so. */
const locked = root.disabled || disabled || pinned;
/*
* Published to the root so the drag can read it on the UI thread. A pin is
* resolved while a finger is moving, where the props of a row two places away
* are not reachable.
*/
useEffect(() => {
setPinned(id, pinned);
}, [id, pinned, setPinned]);
const index = indexOf(id);
const isActive = activeItem === id;
const tick = useCallback(() => selectionTick(), []);
/*
* Read off a ref inside the worklets rather than captured by them: `begin`
* and `settled` change identity whenever the caller's callbacks do, and a
* gesture rebuilt mid-drag is a gesture that drops the drag.
*/
const handlers = useRef({ begin, settled, tick });
handlers.current = { begin, settled, tick };
const notifyBegin = useCallback((rowId: string) => {
handlers.current.begin(rowId);
}, []);
const notifySettled = useCallback((rowId: string) => {
handlers.current.settled(rowId);
}, []);
const notifyTick = useCallback(() => {
handlers.current.tick();
}, []);
const pan = useMemo(() => {
const gesture = Gesture.Pan()
.enabled(!locked)
.onStart((event) => {
activeId.value = id;
translate.value = 0;
order.value = [...rendered.value];
fingerY.value = event.absoluteY;
/*
* Claims the row: a landing spring left over from the previous drop
* finds this number changed and leaves the row alone rather than
* putting down one that has just been picked back up.
*/
dragSeq.value += 1;
/*
* Left at rest when motion is turned down, so the row does not grow.
* The surface and the shadow it gets in the same moment are not
* motion and stay either way — they are what stop the lifted row
* being see-through, which is not a preference.
*/
lift.value = reducedMotion ? 0 : withSpring(1, LIFT);
/*
* Taken here rather than on the JS side: `notifyBegin` lands a frame
* or more later, and by then a fast flick may already have moved the
* scroller — which would be read as the finger having moved.
*/
scrollAtStart.value = scrollOffset.value;
runOnJS(notifyBegin)(id);
})
.onUpdate((event) => {
if (activeId.value !== id) return;
/*
* The pan reports the finger against the screen, so every point the
* list has scrolled underneath it since the lift has to be added
* back — otherwise a row slides out from under the finger the moment
* a drag at the edge starts scrolling.
*/
translate.value =
event.translationY + (scrollOffset.value - scrollAtStart.value);
fingerY.value = event.absoluteY;
const list = order.value;
const map = heights.value;
const current = list.indexOf(id);
if (current < 0) return;
const top = slotOffset(rendered.value, id, map, gap) + translate.value;
const to = targetIndex(list, current, top, map[id] ?? 0, map, gap);
if (to === current) return;
const next = moveWithPinned(
list,
rendered.value,
pinnedIds.value,
id,
current,
to
);
// A move that only pinned rows could have absorbed leaves the order
// exactly as it was, and there is nothing to feel or to redraw.
if (next[current] === id) return;
order.value = next;
if (haptics) runOnJS(notifyTick)();
})
.onFinalize(() => {
if (activeId.value !== id) return;
/*
* The row springs to where its new slot sits *in the old layout* —
* the layout has not changed yet and will not until the drop is
* reported, which is what the callback below does once the row has
* stopped moving.
*/
const landing =
slotOffset(order.value, id, heights.value, gap) -
slotOffset(rendered.value, id, heights.value, gap);
/*
* Released here rather than once the row has landed, so the shrink and
* the drop are one movement. `LIFT` is the faster spring of the two,
* so the row is back at its own size a little before it arrives.
*/
dragSeq.value += 1;
const seq = dragSeq.value;
lift.value = reducedMotion ? 0 : withSpring(0, LIFT);
/*
* Runs whether or not the spring finished. An interrupted spring used
* to leave `activeId` set, which left the row lifted for good and the
* drop unreported; `seq` is what tells the two cases apart, because
* the only interruption that should be ignored is the row being picked
* up again.
*
* `activeId` is checked as well as `seq` because reporting the drop is
* itself what interrupts the spring: the caller applies the reorder,
* and the reset that follows puts `translate` back to rest, which ends
* the animation and calls this a second time under the same `seq`.
*/
const land = () => {
'worklet';
if (dragSeq.value !== seq || activeId.value === null) return;
activeId.value = null;
runOnJS(notifySettled)(id);
};
if (reducedMotion) {
translate.value = landing;
land();
return;
}
translate.value = withSpring(landing, LAND, land);
});
if (activation === 'longPress') return gesture.activateAfterLongPress(longPressDelay);
/*
* On a handle the drag starts on movement rather than on a hold, but not
* on the first pixel: a list inside a scroller has to be able to scroll
* from a finger that happened to land on a grip.
*/
return gesture.activeOffsetY([-HANDLE_SLOP, HANDLE_SLOP]);
}, [
activation,
activeId,
locked,
dragSeq,
fingerY,
gap,
haptics,
heights,
id,
lift,
longPressDelay,
notifyBegin,
notifySettled,
notifyTick,
order,
reducedMotion,
rendered,
scrollAtStart,
scrollOffset,
translate,
]);
const style = useAnimatedStyle(() => {
/*
* Read rather than animated here. The springs live on `translate` and
* `lift`, which the gesture drives; starting one from inside the style
* instead would re-enter it on every frame of the drag, because this
* worklet re-runs every time the row it is carrying moves.
*/
if (activeId.value === id) {
return {
transform: [
{ translateY: translate.value },
{ scale: 1 + lift.value * (LIFT_SCALE - 1) },
],
};
}
/*
* Read, not worked out. This worklet re-runs on every frame of a drag —
* it closes over the value the carried row rides on — so summing the
* heights above this row here, twice, made a list cost the square of its
* length to drag. The root derives every row's offset in one pass instead,
* and only when the arrangement actually changes.
*/
const offset = offsets.value[id] ?? 0;
/*
* Only animated while a drag is in flight, and the difference is the whole
* end of the drop. When the caller applies the reorder the rows move in
* the tree and every offset falls to zero on the same commit — springing
* to it would send the row that was just dropped sliding back across the
* distance it had travelled, in a slot it was already sitting in.
*/
const settling = activeId.value === null;
return {
transform: [
{ translateY: settling || reducedMotion ? offset : withSpring(offset, DISPLACE) },
{ scale: 1 },
],
};
});
const onLayout = useCallback(
(event: LayoutChangeEvent) => measured(id, event.nativeEvent.layout.height),
[id, measured]
);
const itemContext = useMemo(
() => ({
id,
index,
isActive,
disabled: locked,
handleGesture: activation === 'handle' ? pan : null,
}),
[id, index, isActive, locked, activation, pan]
);
/**
* A drag is invisible to a screen reader — there is nothing to announce and
* no way to discover it from the row. Moving by whole slots is published as
* an accessibility action instead, which is the only path to reordering for
* someone who is not dragging anything.
*
* The actions sit wherever the drag does. A handle list keeps them on the
* grip, which is an element in its own right; put here they would never be
* offered, because the actions of a view that is not itself an accessibility
* element are not reachable, and a row full of text is not one. A long-press
* list has no grip and gives the whole row to the drag, so the row becomes
* the element — which is what a screen reader wants from a row in any case.
*/
const carriesActions = activation === 'longPress' && !locked;
const a11y = carriesActions
? [
{ name: 'moveUp', label: 'Move up' },
{ name: 'moveDown', label: 'Move down' },
]
: undefined;
const row = (
{
if (event.nativeEvent.actionName === 'moveUp') step(id, -1);
if (event.nativeEvent.actionName === 'moveDown') step(id, 1);
}}
style={style}
/*
* A carried row is drawn over the ones it is passing, so it has to have
* a surface of its own — the row itself is only a box around whatever
* the caller put inside it, and a good deal of what people put there
* (an outlined `Item`, a bare `View`) has no background at all. Without
* this the lifted row is see-through and the list can be read straight
* through the middle of it.
*
* After `className` so the surface holds whatever else the row is
* wearing, and before `activeClassName`, which is the way out.
*/
className={cn(
'w-full',
className,
isActive && 'z-10 rounded-xl bg-card shadow-lg',
isActive && activeClassName
)}
{...props}
>
{children}
);
return (
{activation === 'longPress' ? (
{row}
) : (
row
)}
);
}
SortableItem.displayName = 'Sortable.Item';
/* -------------------------------------------------------------------------- */
/* Handle */
/* -------------------------------------------------------------------------- */
export interface SortableHandleProps extends Omit {
className?: string;
/** Replaces the grip glyph. Anything at all — the drag is on the box. */
children?: ReactNode;
/** What a screen reader calls the grip. */
accessibilityLabel?: string;
}
/**
* The part of a row that lifts it.
*
* A grip exists so the rest of the row does not have to be given up to the
* drag: a row with a button, a checkbox or a link on it needs those to stay
* pressable, and a long press on the whole row takes all of them. It is sized
* to be hit rather than to be seen, which is why the box around the glyph is
* larger than the glyph.
*
* Inert in a list that lifts on a long press — the whole row already carries
* the gesture there, and a second one inside it would fight the first.
*/
function SortableHandle({
className,
children,
accessibilityLabel = 'Drag to reorder',
...props
}: SortableHandleProps) {
const { activation, disabled: rootDisabled, step } = useSortableRoot('Sortable.Handle');
const item = useContext(SortableItemContext);
/*
* The grip is furniture rather than content, so it takes the muted
* foreground — read from the theme rather than written down, because a hex
* here stops being right the moment the theme inverts.
*/
const muted = useCSSVariable('--color-muted-foreground');
const tint = typeof muted === 'string' ? muted : undefined;
const locked = rootDisabled || item?.disabled;
/*
* `adjustable` promises an element that answers a swipe up or down, and the
* promise was never kept: the grip published the role and nothing else, so
* the one part of a row a screen reader could reach did nothing at all.
* Moving by whole slots is what it was always meant to do. The same move is
* offered as a named action too, because a swipe says nothing about which
* way the row is going to travel.
*/
const move = (delta: number) => {
if (locked || !item) return;
step(item.id, delta);
};
const glyph = (
{
const action = event.nativeEvent.actionName;
if (action === 'increment' || action === 'moveUp') move(-1);
if (action === 'decrement' || action === 'moveDown') move(1);
}}
className={cn('items-center justify-center px-2 py-1.5', locked && 'opacity-40', className)}
{...props}
>
{children ?? }
);
if (activation !== 'handle' || !item?.handleGesture) return glyph;
return {glyph};
}
SortableHandle.displayName = 'Sortable.Handle';
export const Sortable = Object.assign(SortableRoot, {
Item: SortableItem,
Handle: SortableHandle,
});