Skip to main content

SortableContainer

List whose rows can be reordered by dragging. Press and hold a row to pick it up; the rows it passes slide out of the way, and dropping it reports the reordered array.

It does not scroll — it sizes to its rows and lays them out in place. Nest it in a ScrollContainer when the list is taller than the screen.

SortableContainer renders no row of its own — every row comes from your renderItem, the same way ListContainer works. What it owns is the drag: the gesture, the slot each row currently occupies, and the animation between slots. That machinery lives in SortableItem, which the container wraps around each renderItem result for you.

Basic Usage​

import { SortableContainer, ListPickerItem } from '@multinaire/expo-ui';

const [tasks, setTasks] = useState(TASKS);

<SortableContainer
data={tasks}
keyExtractor={(task) => task.id}
onReorder={(next) => setTasks(next)}
renderItem={({ item }) => (
<ListPickerItem leading="TextalignJustifyleft" text={item.title} />
)}
/>

Requirements​

SortableContainer uses react-native-gesture-handler, which must be installed:

npx expo install react-native-gesture-handler

No further setup is needed — MultinaireUI mounts the required GestureHandlerRootView at the root of your app.

Props​

PropTypeDefaultDescription
dataT[]—Array of items to render, in their current order
keyExtractor(item: T, index: number) => string—Returns a stable key for an item. Required — reorder animations track rows by key, not index
renderItem({ item, index, isDragging }) => ReactNode—Render function for each row
onReorder(data: T[], from: number, to: number) => void—Called when a drag ends on a new slot, with the reordered array and the indices moved between
itemHeightnumberheight.mediumHeight of every row in pixels, enforced on the row wrapper
gapnumbergap.mediumGap between rows in pixels
longPressDelaynumber200How long a press must be held before a drag starts, in milliseconds
disabledbooleanfalseDisable dragging while still rendering the list
isLoadingbooleanfalseShow loading skeleton rows in place of content
itemCountnumber8Number of skeleton rows to show while loading
flexnumber—Flex grow value
marginSpacingProps—Outer margin
paddingSpacingProps—Inner padding
styleContainerStyleProps—Additional styles applied to the container
testIDstring—Test identifier for UI automation

renderItem arguments​

FieldTypeDescription
itemTThe item being rendered
indexnumberIndex of the item in data. Unchanged while dragging — only the row's on-screen slot moves
isDraggingbooleanWhether this row is the one currently being dragged

Every row is the same height​

Rows are absolutely positioned and the drag maths works in whole slots of itemHeight + gap, so the container gives every row wrapper an explicit height rather than measuring its content. itemHeight defaults to the theme's height.medium, which is exactly the height of a ListPickerItem.

If your row is taller than that, set itemHeight to match — otherwise it will be clipped or overflow into its neighbour.

<SortableContainer
itemHeight={variables.height.large}
data={players}
keyExtractor={(player) => player.id}
onReorder={setPlayers}
renderItem={({ item }) => <PlayerCard player={item} />}
/>

Examples​

Reacting to the dragged row​

<SortableContainer
data={items}
keyExtractor={(item) => item.id}
onReorder={setItems}
renderItem={({ item, isDragging }) => (
<Card
flex={1}
horizontal
alignItems="center"
padding={{ horizontal: 12 }}
borderColor={isDragging ? 'primary' : 'backgroundVariant'}
>
<Typography flex={1}>{item.title}</Typography>
<Icon icon="CustomGrid" color={isDragging ? 'primary' : 'neutral'} />
</Card>
)}
/>

Persisting the new order​

onReorder hands you the reordered array plus the indices the row moved between, so you can update local state and the server from the same callback.

<SortableContainer
data={items}
keyExtractor={(item) => item.id}
onReorder={(next, from, to) => {
setItems(next);
saveOrder(next.map((item) => item.id)).catch(() => setItems(items));
analytics.track('reordered', { from, to });
}}
renderItem={({ item }) => <ListPickerItem text={item.title} />}
/>

With a loading state​

<SortableContainer
isLoading={isLoading}
itemCount={5}
data={items}
keyExtractor={(item) => item.id}
onReorder={setItems}
renderItem={({ item }) => <ListPickerItem text={item.title} />}
/>

Read-only while saving​

<SortableContainer
disabled={isSaving}
data={items}
keyExtractor={(item) => item.id}
onReorder={setItems}
renderItem={({ item }) => <ListPickerItem text={item.title} />}
/>

SortableItem​

The row wrapper SortableContainer puts around each renderItem result. It owns the pan gesture and the translation that carries a row between slots, and renders nothing visible of its own.

You will rarely reach for it directly — the container renders one per item. It is exported for the case where you are building a different container over the same drag mechanics, and it derives nothing on its own: every prop is required, and the shared positions map is yours to create and keep in step with your data.

PropTypeDescription
itemKeystringKey of the item this row renders, used to look up its slot in positions
countnumberTotal number of rows, used to clamp the drag target
stridenumberDistance between the top of one row and the next (itemHeight + gap)
itemHeightnumberHeight of the row in pixels
disabledbooleanDisable the pan gesture
longPressDelaynumberHow long a press must be held before the drag activates, in milliseconds
positionsSharedValue<Record<string, number>>Shared map of item key to the slot index it currently occupies
onDragStart(key: string) => voidCalled on the JS thread when this row is lifted
onDragEnd(from: number, to: number) => voidCalled on the JS thread when this row is dropped
childrenReactNodeThe rendered row content

positions is a Reanimated shared value the rows both read and write: dragging one row rewrites the whole map on the UI thread so its neighbours slide out of the way. Every SortableItem in one list must be handed the same shared value, and it must hold an entry for every itemKey — a row whose key is missing stays parked at the top of the list rather than at its slot.

Each row is absolutely positioned at top: 0, stretched to full width, and translated into place, so the element you render them into needs an explicit height (stride × count − gap) to reserve the space.

const positions = useSharedValue(
Object.fromEntries(items.map((item, index) => [item.id, index])),
);

<Container height={items.length * stride - gap}>
{items.map((item) => (
<SortableItem
key={item.id}
itemKey={item.id}
count={items.length}
stride={stride}
itemHeight={itemHeight}
disabled={false}
longPressDelay={200}
positions={positions}
onDragStart={setDraggingKey}
onDragEnd={handleDragEnd}
>
<ListPickerItem text={item.title} />
</SortableItem>
))}
</Container>

Notes and limitations​

  • Filtering and sorting don't mix. Don't render a filtered subset — dragging row 2 above row 1 of a filtered view has no unambiguous meaning in the source array. Reorder the full list.
  • A lifted row stays inside the container. Its travel is pinned to the first and last slot, so dragging past either end pins it there rather than letting it float over the rest of the screen.
  • It doesn't scroll. The container sizes to its rows. For a list taller than the screen, nest it in a ScrollContainer — but note the outer scroll won't follow a row dragged to the edge, so a row can only be moved within the visible part of the list. Long lists are better paginated or grouped than dragged end to end.
  • Rows that also handle taps. The whole row is the drag target, so a row that is itself pressable will see its press gesture compete with the drag. Prefer either a tappable row or a draggable one. ListPickerItem renders non-interactive when you omit its onPress.