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
| Prop | Type | Default | Description |
|---|---|---|---|
data | T[] | — | 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 |
itemHeight | number | height.medium | Height of every row in pixels, enforced on the row wrapper |
gap | number | gap.medium | Gap between rows in pixels |
longPressDelay | number | 200 | How long a press must be held before a drag starts, in milliseconds |
disabled | boolean | false | Disable dragging while still rendering the list |
isLoading | boolean | false | Show loading skeleton rows in place of content |
itemCount | number | 8 | Number of skeleton rows to show while loading |
flex | number | — | Flex grow value |
margin | SpacingProps | — | Outer margin |
padding | SpacingProps | — | Inner padding |
style | ContainerStyleProps | — | Additional styles applied to the container |
testID | string | — | Test identifier for UI automation |
renderItem arguments
| Field | Type | Description |
|---|---|---|
item | T | The item being rendered |
index | number | Index of the item in data. Unchanged while dragging — only the row's on-screen slot moves |
isDragging | boolean | Whether 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.
| Prop | Type | Description |
|---|---|---|
itemKey | string | Key of the item this row renders, used to look up its slot in positions |
count | number | Total number of rows, used to clamp the drag target |
stride | number | Distance between the top of one row and the next (itemHeight + gap) |
itemHeight | number | Height of the row in pixels |
disabled | boolean | Disable the pan gesture |
longPressDelay | number | How long a press must be held before the drag activates, in milliseconds |
positions | SharedValue<Record<string, number>> | Shared map of item key to the slot index it currently occupies |
onDragStart | (key: string) => void | Called on the JS thread when this row is lifted |
onDragEnd | (from: number, to: number) => void | Called on the JS thread when this row is dropped |
children | ReactNode | The 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.
ListPickerItemrenders non-interactive when you omit itsonPress.