In Flutter's ReorderableListView, how do the default drag handles differ between mobile and desktop, and how do you add a custom handle?
answer
- buildDefaultDragHandles defaults to true
- long press on phones
- trailing drag_handle icon on desktop
- ReorderableDragStartListener with the index
- proxyDecorator styles the dragged row
basics
~20 sWith buildDefaultDragHandles true, phones start a drag on a long press anywhere on the row and desktops get a drag_handle icon at the trailing edge. For a custom handle, set it false and wrap your icon in ReorderableDragStartListener with the row's index.
solid answer
~40 s`ReorderableListView` has `buildDefaultDragHandles`, `true` by default, and chooses by `Theme.of(context).platform`. On Android and iOS it wraps each whole row in a `ReorderableDelayedDragStartListener`, so a long press anywhere starts the drag. On macOS, Windows and Linux it stacks an `Icons.drag_handle` inside a `ReorderableDragStartListener` over the row's trailing edge, which starts the drag immediately. The desktop icon can sit on top of a trailing checkbox, and some designs want a visible handle on phones too, so I set `buildDefaultDragHandles: false` and wrap my own handle in `ReorderableDragStartListener(index: index, child: ...)` for an immediate drag, or the delayed variant for long press. `proxyDecorator` controls how the lifted row looks; the default wraps it in `Material` and animates elevation from 0 to 6.
code
dart · 24 linesReorderableListView.builder(
buildDefaultDragHandles: false,
itemCount: _items.length,
onReorderItem: _move,
proxyDecorator: (child, index, animation) => ScaleTransition(
scale: Tween<double>(begin: 1, end: 1.03).animate(animation),
child: Material(elevation: 4, child: child),
),
itemBuilder: (context, index) {
final item = _items[index];
return ListTile(
key: ValueKey(item.id),
leading: ReorderableDragStartListener(
index: index,
child: const Icon(Icons.drag_indicator),
),
title: Text(item.label),
trailing: Checkbox(
value: item.packed,
onChanged: (value) => _setPacked(item.id, value ?? false),
),
);
},
)go deeper
Remember that phones reorder on a long press by default and desktops show a drag icon, and that buildDefaultDragHandles false lets you place your own.
Explain the two listeners, immediate and delayed, why the delayed one suits a whole-row handle on touch, and what proxyDecorator changes while a row is lifted.
Show the production fixes: a desktop handle covering a trailing control, rows that must not move, long-press conflicts, and one handle design across platforms.
Decide whether reordering is discoverable enough with a long press alone, and weigh platform-default behaviour against one consistent handle across the product.
## What a drag handle is here A **drag handle** is the part of a row the user touches to pick it up. In Flutter's reorderable lists, a handle is simply a widget wrapped in a **drag start listener** that knows the row's index. Two listeners exist in the widgets layer: - `ReorderableDragStartListener(index:, child:)`: a drag starts as soon as the pointer moves; best for a small, obvious handle icon; - `ReorderableDelayedDragStartListener(index:, child:)`: a drag starts only after a long press (it uses a delayed multi-drag recognizer); best when the whole row is the handle, so normal scrolling still works. Both take `enabled`, `true` by default, to switch dragging off for a row. ## The defaults: buildDefaultDragHandles `ReorderableListView.buildDefaultDragHandles` defaults to `true`. The widget then picks a style from `Theme.of(context).platform`: | Platform | Default behaviour | Listener used | |---|---|---| | Android, iOS, Fuchsia | long press anywhere on the row | `ReorderableDelayedDragStartListener` around the row | | macOS, Windows, Linux | an `Icons.drag_handle` icon over the trailing edge, with a grab cursor | `ReorderableDragStartListener` around the icon | The reasoning is input: on touch, a whole-row immediate drag would fight with scrolling, so a long press is required; with a mouse, a visible handle is expected and the wheel scrolls. ## When the defaults get in the way Common reasons to turn them off: 1. The desktop icon is stacked on top of the row, so a trailing `Checkbox` or menu button in a `ListTile` ends up underneath it. 2. The design wants a visible handle on phones too, often at the leading edge. 3. Long press is already used on the row for something else, such as a context menu. 4. Some rows, such as a pinned 'Documents' section, must not move. The recipe: - set `buildDefaultDragHandles: false`; - inside each row, wrap the handle widget in `ReorderableDragStartListener(index: index, child: const Icon(Icons.drag_indicator))`; - pass `enabled: false` for rows that must stay put; - keep the row's key on the row itself, as before. The index must be the row's current index in the list, which is why the listener is created in `itemBuilder`, where the index is at hand. ## Styling the lifted row: proxyDecorator While a row is being dragged, the list draws a **proxy**, a copy of the row in an overlay. `proxyDecorator(Widget child, int index, Animation<double> animation)` wraps that copy. The default wraps it in `Material` and animates the elevation from 0 to 6 as the row lifts, which gives the familiar shadow. A custom decorator can scale the row slightly, add a coloured border, or keep a card's rounded shape, and it should use the given animation so the effect eases in and out. ## The drag lifecycle callbacks `onReorderStart(index)` fires when a drag begins and `onReorderEnd(index)` when it ends, even if the row lands where it started. They are the right place for side effects that belong to the gesture, such as haptic feedback or hiding a floating button, while `onReorderItem` stays responsible only for moving the data. ## Packing list example In a travel app's packing list, each row shows a checkbox for 'packed', the item name and a handle. With the defaults, a desktop build would draw its handle over the checkbox. Turning the defaults off and placing a `drag_indicator` icon at the leading edge, wrapped in `ReorderableDragStartListener`, gives one consistent handle on every platform, while the checkbox stays tappable. ## Choosing a handle design | Design | Strength | Weakness | |---|---|---| | whole row, long press (mobile default) | no extra chrome; large target | hard to discover; conflicts with other long-press actions | | visible icon, immediate drag | obvious and fast; scrolling stays free elsewhere | takes horizontal space; small target on phones | | desktop default icon at trailing edge | familiar with a mouse, grab cursor included | stacked over the row, so it can cover trailing controls | Whichever you pick, keep the rest of the row's gestures working: taps on the title, the checkbox and any buttons should behave the same whether or not the row can be dragged.
- Why does the default on phones use a long press rather than an immediate drag on the whole row?Because a vertical drag on a phone is how the user scrolls. If the whole row started a reorder on the first movement, scrolling the list would pick rows up instead. `ReorderableDelayedDragStartListener` waits for a long press, so a quick drag still scrolls; a small dedicated handle can use the immediate listener because it is a deliberate target.
- How do you stop one row, such as a section header, from being dragged?With custom handles, give that row's `ReorderableDragStartListener` `enabled: false`, or leave the listener out. Also guard the data: other rows can still be dropped above or below it, so `onReorderItem` must keep the header's position valid if it matters.
saying these in an interview costs you the question
- Default drag handles look and behave the same on every platform.
- A custom handle needs a GestureDetector that moves the row by hand.
- Wrap the whole row in an immediate ReorderableDragStartListener on phones.
- buildDefaultDragHandles must stay true for reordering to work at all.
- proxyDecorator decides where the dragged row may be dropped.