skip to content

In Flutter's ReorderableListView, how do the default drag handles differ between mobile and desktop, and how do you add a custom handle?

level: middleimportance: nice to knowfreq 22%

answer

  1. buildDefaultDragHandles defaults to true
  2. long press on phones
  3. trailing drag_handle icon on desktop
  4. ReorderableDragStartListener with the index
  5. proxyDecorator styles the dragged row

basics

~20 s

With 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 lines
dart
ReorderableListView.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

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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.