Skip to content
Native Animation

React Native Drag List: Reorder, Autoscroll, and Cancel

By Native Animation · Updated

A React Native drag list reorders items manually by stable ID, using a continuous drag position for presentation and an array update when the move settles. Keep the saved order separate from a draft if people need Apply and Cancel. This guide explains slot math, bounded edge autoscroll, and an editable-session host for Lift Reorder List, including its cancellation limits.

Manual reorder is different from sorting

Sorting derives an order from a field, such as title or date. Manual reordering records a person's preference, such as a playback queue. Running sort() again after a drag would erase that preference. Store an ordered list of IDs or explicit ranks and keep item data separately when your data model calls for it.

Use IDs as keys throughout a drag. An array index describes a position, not an identity. If a row is removed remotely during a move, its old numeric index can now refer to another item. Decide whether to defer external updates until editing ends or cancel the session and rebuild from a fresh snapshot.

For left/right row actions rather than vertical movement, use the swipe actions guide. This tutorial does not turn a swipe-to-delete row into a sortable list.

Map finger movement to row slots

With a fixed row height and gap, define pitch as rowHeight + rowGap. For a drag starting at slot s, a useful continuous position is s + (translationY + currentScroll - startScroll) / pitch, clamped to the first and last slots. Including the scroll delta prevents the dragged row from drifting away from the finger when the content scrolls.

Lift Reorder List follows that model. Neighboring rows derive a gap from the active position and the final target is the nearest slot. A shared-value position keeps movement off React's render loop; the host receives an ordered array after settling. In a custom implementation, use Gesture Handler 3.3 with Reanimated worklets, then use scheduleOnRN for discrete callbacks defined in RN scope. Do not schedule a React array update on every pointer event.

Variable-height rows need another model

The catalog piece accepts one rowHeight and rowGap, not a measured height per item. Wrapping several paragraphs into each row would invalidate uniform slot math. For variable heights, measure row boundaries and choose targets by those boundaries. A virtualized, variable-height data set needs a list architecture designed for that case.

Bound edge autoscroll and handle cancellation

Lift Reorder List checks proximity to the viewport edges during pan updates, clamps the scroll offset to the content range, and compensates the active position by the resulting offset. Its current implementation scrolls on gesture updates; holding the pointer perfectly still at an edge does not guarantee continued scrolling. It is not a frame-driven, stationary-edge autoscroll loop.

For a custom continuous implementation, store the pointer's viewport position and advance scroll from a UI-thread frame callback using elapsed time. Stop that loop on successful drop, failed recognition, system cancellation, and unmount. Test both boundaries and scroll-offset compensation. A constant number of pixels per event makes speed depend on event frequency.

Gesture cancellation is different from Cancel editing

Gesture Handler 3.3 passes a success boolean to onEnd and onFinalize; finalization includes failed or canceled gestures. A custom list can use that value to restore its drag snapshot. Lift Reorder List currently settles to the nearest slot after an active gesture finalizes without checking that flag. It does not expose onDragCancel or a rollback-on-interruption option. Do not assume an incoming system interruption restores the old order.

The host below offers an explicit Cancel editing action. It discards the entire editing session and unmounts the list, including any unfinished movement. That is a product-level transaction boundary, independent of native gesture cancellation.

Build an Apply and Cancel host

Use Expo SDK 58, React Native 0.88.0, Gesture Handler ~3.3.0, Reanimated 4.7.1, Worklets 0.13.0, and Skia 2.14.0. Add Lift Reorder List through the CLI, then follow its README for compatible dependency installation. Wrap the app in GestureHandlerRootView as described in the SDK 58 setup. In Bun projects use bunx instead of npx.

Place this complete file beside the copied lift-reorder-list folder, outside Expo Router routes. Render it inside your app's safe-area screen. It uses local state and makes no server request.

import { useState } from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { LiftReorderList, type LiftReorderItem } from './lift-reorder-list';

const INITIAL: LiftReorderItem[] = Array.from({ length: 12 }, (_, index) => ({
  id: 'track-' + index,
  title: 'Track ' + (index + 1),
  subtitle: 'Northline Sessions',
}));

export function validOrder(previous: LiftReorderItem[], next: LiftReorderItem[]): boolean {
  const ids = new Set(previous.map((item) => item.id));
  return ids.size === previous.length && next.length === previous.length
    && new Set(next.map((item) => item.id)).size === next.length
    && next.every((item) => ids.has(item.id));
}

type EditorProps = {
  initial: LiftReorderItem[];
  onReview: (next: LiftReorderItem[]) => void;
  onCancel: () => void;
};

function OrderEditor({ initial, onReview, onCancel }: EditorProps) {
  const [draft, setDraft] = useState(initial);
  return <View style={styles.editor}>
    <View style={styles.list}>
      <LiftReorderList items={initial} rowHeight={64} rowGap={4}
        onOrderChange={setDraft} accessibilityLabel="Edit playback order" />
    </View>
    <Button title="Review settled order" onPress={() => onReview(draft)} />
    <Button title="Cancel editing" onPress={onCancel} />
  </View>;
}

export function ReorderExample() {
  const [saved, setSaved] = useState(INITIAL);
  const [draft, setDraft] = useState(INITIAL);
  const [mode, setMode] = useState<'saved' | 'editing' | 'review'>('saved');
  const [error, setError] = useState('');
  return (
    <GestureHandlerRootView style={styles.root}>
      <View style={styles.screen}>
        <Text style={styles.heading}>Playback queue</Text>
        {mode === 'editing' ? <OrderEditor initial={draft}
          onCancel={() => setMode('saved')}
          onReview={(next) => { setDraft(next); setMode('review'); }} /> : <>
          <Text style={styles.copy}>{mode === 'review' ? 'Review the last settled order:' : 'Saved order:'}</Text>
          {(mode === 'review' ? draft : saved).map((item, index) =>
            <Text key={item.id} style={styles.copy}>{index + 1}. {item.title}</Text>)}
          {mode === 'review' ? <>
            <Button title="Apply this order" onPress={() => {
              if (!validOrder(saved, draft)) {
                setError('The item set changed. Cancel and try again.');
                return;
              }
              setSaved(draft);
              setMode('saved');
            }} />
            <Button title="Continue editing" onPress={() => setMode('editing')} />
            <Button title="Cancel editing" onPress={() => setMode('saved')} />
          </> : <Button title="Edit order" onPress={() => {
            setDraft(saved); setError(''); setMode('editing');
          }} />}
        </>}
        {error !== '' && <Text accessibilityRole="alert" style={styles.copy}>{error}</Text>}
      </View>
    </GestureHandlerRootView>
  );
}
const styles = StyleSheet.create({
  root: { flex: 1 },
  screen: { padding: 20, gap: 12, backgroundColor: '#FFFFFF' },
  editor: { gap: 12 },
  list: { height: 320 },
  heading: { fontSize: 26, color: '#231D2B' },
  copy: { fontSize: 16, lineHeight: 24, color: '#393240' },
});

Review a settled snapshot before applying

Review settled order unmounts the interactive list and displays the last array delivered by onOrderChange. A release still springing when Review is pressed may not be included. The preview makes the exact order to be applied visible before the second confirmation. Continue editing remounts from that visible draft.

There is no public dragging/settling callback with which to disable Apply until a pending move finishes. A timed delay would only guess. For a product that requires one-tap saving of the current in-flight target, adapt the copied component to expose an explicit interaction lifecycle first.

Why remount instead of treating items as controlled

The piece initializes its internal order from items; it is not a fully controlled list that resynchronizes on every prop change. reset() restores the current prop array and invokes onOrderChange; it is not a dedicated cancel-without-callback operation. Starting a fresh mounted editor gives this example an explicit snapshot boundary. The component also exposes move(fromIndex, toIndex) for programmatic movement.

Persist order and reconcile external updates

The demo saves locally. In production, send the reviewed IDs and a server revision, validate that the response contains the expected membership, and only then mark the saved order accepted. Keep failure feedback visible and retain a retryable draft. Disable conflicting submissions while saving; a late response must not replace a newer session.

If the server inserts or deletes an item during editing, choose a policy before merging. For a short queue, canceling editing and loading the new snapshot is often easier to explain than silently applying a stale order. The example's membership validator rejects duplicates, missing IDs, and unknown IDs; it does not implement server concurrency or merge remote metadata.

Lift Reorder List mounts its rows in an animated ScrollView rather than virtualizing them. Use it for a bounded queue and profile your item count. Do not assume FlatList-scale behavior or nest it in another vertical scroller without testing gesture arbitration.

Accessibility and reduced motion

The piece provides labeled Move up and Move down accessibility actions, so dragging is not the only way to change order. Reduced Motion removes lift scale, tilt, and animated settling while preserving moves. Test those actions with VoiceOver and TalkBack in your host; a simulator or browser page does not prove the complete focus experience.

The demo uses ordinary buttons for editing, review, apply, and cancel, and shows the saved order as text after confirmation. When replacing a focused editor in a real screen, deliberately move accessibility and keyboard focus to the corresponding review heading or saved-list control. Add an appropriate status announcement for a successful save without announcing every drag position.

Each published piece is reviewed on physical iPhone and Android devices with Expo Go before release. The host's persistence, focus transitions, and interruption policy still need integration testing.

FAQ

How do I build a React Native drag list?

Keep stable item IDs, translate the active row using a shared position, open neighboring slots from that position, and commit an array after the drop settles. Account for scrolling in the drag coordinate calculation.

Does Lift Reorder List support autoscroll?

Yes, its pan updates scroll near viewport edges within the content bounds. Stationary-edge scrolling is not driven by a continuous frame loop in the current implementation.

Does a canceled gesture restore the original order?

Not automatically in this piece. It settles an active gesture when finalization runs. Use an explicit draft session for product-level Cancel, or adapt the component to check Gesture Handler's success flag for rollback on system interruption.

Can I use it for thousands of variable-height rows?

It uses a fixed row pitch and mounts its rows without virtualization. Choose a virtualized architecture with measured geometry for very large or variable-height data sets.