skip to content

Reorderable & Animated Lists

ReorderableListView lets users drag rows into a new order, and AnimatedList animates rows in and out as they are inserted or removed. Interviewers ask how the data and the list stay in sync.

part ofFlutteroverview, primer and where to startread it →
on this pageshow

explore

questions

4

In Flutter, how do you make a list the user can drag into a new order with ReorderableListView, and what must onReorderItem do?

level: juniorimportance: should knowfreq 40%

answer

  1. every row needs a key
  2. old index and new index
  3. remove, then insert, in setState
  4. newIndex already adjusted since 3.44
  5. onReorder is the deprecated one

basics

~20 s

Build the rows with ReorderableListView or its builder constructor, give every row a key, and in onReorderItem move the item in your data with removeAt(oldIndex) then insert(newIndex) inside setState; since Flutter 3.44 newIndex already accounts for the removal.

solid answer

~40 s

`ReorderableListView` (or `ReorderableListView.builder`) handles the drag, the gap animation and auto-scrolling; my job is the data. Every row must carry a key, otherwise it fails with 'Every item of ReorderableListView must have a key.'. When a row is dropped somewhere new, `onReorderItem(oldIndex, newIndex)` fires, and I update the list inside `setState`: `final item = items.removeAt(oldIndex); items.insert(newIndex, item);`. In Flutter 3.44 and later `newIndex` is already the index after the removal, so no correction is needed. The older `onReorder` callback, now deprecated, reported the index before removal and every handler had to do `if (oldIndex < newIndex) newIndex -= 1;`. Passing both callbacks trips an assertion, and keeping that correction after migrating puts rows one slot too high. Dropping a row back in place does not call `onReorderItem`; `onReorderEnd` still fires.

code

dart · 39 lines
dart
import 'package:flutter/material.dart';

class PackingItem {
  const PackingItem({required this.id, required this.label});
  final String id;
  final String label;
}

class PackingList extends StatefulWidget {
  const PackingList({super.key, required this.initialItems});
  final List<PackingItem> initialItems;

  @override
  State<PackingList> createState() => _PackingListState();
}

class _PackingListState extends State<PackingList> {
  late final List<PackingItem> _items = [...widget.initialItems];

  void _move(int oldIndex, int newIndex) {
    setState(() {
      // onReorderItem reports newIndex after the removal: no correction.
      final item = _items.removeAt(oldIndex);
      _items.insert(newIndex, item);
    });
  }

  @override
  Widget build(BuildContext context) {
    return ReorderableListView.builder(
      itemCount: _items.length,
      onReorderItem: _move,
      itemBuilder: (context, index) {
        final item = _items[index];
        return ListTile(key: ValueKey(item.id), title: Text(item.label));
      },
    );
  }
}

go deeper

for a junior

Recall the three parts: ReorderableListView or its builder, a key on every row, and onReorderItem that removes the item at oldIndex and inserts it at newIndex inside setState.

for a middle

Explain what changed in 3.44: onReorder reported newIndex before removal and needed a minus-one fix, while onReorderItem is already corrected and is skipped when nothing moved.

for a senior

Show migration judgement: find handlers that kept the old correction, move persistence to the state layer or onReorderEnd, and keep the data stable during a drag.

for a principal

Decide where ordering lives, locally or synced, and how a reorder on one device merges with edits from another before offering drag-to-reorder at all.

## What ReorderableListView does `ReorderableListView` is the Material widget for a **drag-to-reorder list**: the user picks a row up, the other rows open a gap as it moves, the list auto-scrolls near its edges, and on drop the widget tells the app the old and new positions. It does **not** reorder anything itself. The data belongs to the app, and the widget only reports what the user asked for. It comes in two shapes: - `ReorderableListView(children: [...])` for a short, fixed list of rows; - `ReorderableListView.builder(itemCount:, itemBuilder:)` for a lazy list built on demand. Both take the same callbacks. The widgets layer has the same machinery without Material styling as `ReorderableList` and `SliverReorderableList`. ## Keys are mandatory Each row must carry a key; the widget checks it and fails with 'All children of this widget must have a key.' (children constructor) or 'Every item of ReorderableListView must have a key.' (builder). A stable key taken from the data, such as `ValueKey(item.id)`, is the usual choice. Why keys preserve identity when rows move is a topic of its own; here it is simply a requirement. ## The callbacks | Callback | When it runs | Typical use | |---|---|---| | `onReorderItem(oldIndex, newIndex)` | on drop, when the index changed | move the item in your data | | `onReorderStart(index)` | when a drag begins | haptics, hide other controls | | `onReorderEnd(index)` | on every drop, even in place | restore controls, persist order | | `onReorder(oldIndex, newIndex)` | deprecated since 3.44 | migrate to `onReorderItem` | Exactly one of `onReorderItem` and `onReorder` must be given; passing both fails with 'The onReorder callback is obsolete and is replaced by onReorderItem.'. ## The newIndex change in 3.44 The old `onReorder` reported `newIndex` as a position in the list **before** the dragged item was removed. Moving an item down therefore needed a correction, because removing it first shortens the list by one: 1. The list is `[passport, charger, adapter, socks]`. 2. The user drags `passport` (index 0) to just below `adapter`. 3. `onReorder` reported `newIndex == 3`; the handler had to subtract one before inserting. 4. `onReorderItem` reports `newIndex == 2`, ready to use after `removeAt(0)`. The framework now does the subtraction itself: when `newIndex > oldIndex` it decrements before calling `onReorderItem`, and it skips the call entirely when the two are equal. The migration is not applied by `dart fix`, because the parameter's meaning changed rather than its name. The classic migration bug is to rename the callback and keep the old `if (oldIndex < newIndex) newIndex -= 1;`; downward moves then land one row above the drop point. ## Updating the data The handler should be small and synchronous: - remove the item at `oldIndex` and insert it at `newIndex`, inside `setState` or through whatever state object owns the list; - persist the new order afterwards if needed, for example in `onReorderEnd` or from the state layer; - do not rebuild the row list from a different source during the drag, or the dragged row's index goes stale. ## A packing list in a travel app A trip's packing list is the natural fit: the traveller drags 'passport' to the top and 'socks' further down. Each `PackingItem` has an `id`, the rows use `ValueKey(item.id)`, and `onReorderItem` moves the item in a `List<PackingItem>`. Because the list is short, either constructor works; the builder is the habit worth keeping for longer lists. ## Checklist for a reorderable list 1. Choose the constructor: `children` for a handful of fixed rows, `.builder` for anything that grows. 2. Give every row a stable key derived from the data, never from the index, since the index is exactly what changes. 3. Implement `onReorderItem` as a plain remove-then-insert on the data, with no index arithmetic. 4. Put side effects that belong to the gesture, such as haptics or saving, in `onReorderStart` and `onReorderEnd` or in the state layer. 5. When upgrading an older codebase, search for `onReorder:` and for the `newIndex -= 1` line, and remove the line in the same change as the rename. 6. Test a move up and a move down: an off-by-one only shows in one direction.

  • What does the old onReorder handler look like, and why can dart fix not migrate it?
    It reported `newIndex` before removal, so handlers began with `if (oldIndex < newIndex) newIndex -= 1;`. `onReorderItem` takes the same two ints but with the corrected meaning. A mechanical rename would keep the correction and shift every downward move by one, so the migration needs a person to delete that line, or to add one back to reproduce the old value.
  • Where would you save the new order to storage?
    Not inside the drag. Update the in-memory list in `onReorderItem`, then persist from the state layer or in `onReorderEnd`, which fires on every drop even when nothing moved. Saving asynchronously and then rebuilding the list from storage mid-drag risks stale indices.

saying these in an interview costs you the question

  • ReorderableListView reorders the children list for you, so no handler is needed.
  • Keep the if (oldIndex < newIndex) newIndex -= 1 correction after moving to onReorderItem.
  • Rows can omit keys because the list tracks them by index.
  • Pass both onReorder and onReorderItem during a migration to be safe.
  • onReorderItem runs on every drop, even when the row returns to its place.
open as a page

In Flutter, how does AnimatedList animate rows in and out, and why must insertItem and removeItem be paired with changes to your data?

level: middleimportance: should knowfreq 38%

basics

~20 s

AnimatedList keeps its own item count, starting from initialItemCount, and changes it only through AnimatedListState.insertItem and removeItem, reached with a GlobalKey. Change your data in the same step, and give removeItem a builder that draws the removed item.

open as a page

A Flutter packing list built on AnimatedList sometimes deletes the wrong item or throws a RangeError when users tap quickly; what goes wrong and how do you fix it?

level: seniorimportance: should knowfreq 22%

basics

~20 s

The index a row captured goes stale: the removed row stays on screen and tappable, so a second tap removes the next item, and data changed without matching insertItem or removeItem calls makes itemBuilder index past the end.

open as a page

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%

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.

open as a page