Flutter Motion Kit

Previewable Flutter animations + pitfall guide · one-click reuse from Claude Code

← Back

AnimatedList insert & remove

explicit · difficulty 3/5 · verified Flutter 3.32 / 2026-06

An AnimatedList driven by a GlobalKey<AnimatedListState>. Adding calls insertItem with a SizeTransition+FadeTransition entrance; tapping removes via removeItem, whose builder animates the just-removed item out.

▶︎ Running for real (Flutter 3.32 · self-hosted, not a recording)

Code

// ✅ Recommended: AnimatedList + GlobalKey<AnimatedListState>, the backing list
// kept in lockstep, and a remove builder that animates the captured item out.
// Paste straight into DartPad (https://dartpad.dev) to run.
import 'package:flutter/material.dart';

void main() => runApp(const _App());

class _App extends StatelessWidget {
  const _App();
  @override
  Widget build(BuildContext context) => MaterialApp(
    debugShowCheckedModeBanner: false,
    theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.indigo),
    home: const _Demo(),
  );
}

class _Demo extends StatefulWidget {
  const _Demo();
  @override
  State<_Demo> createState() => _DemoState();
}

class _DemoState extends State<_Demo> {
  // The single source of truth that must stay in lockstep with the AnimatedList.
  final List<String> _items = ['Apple', 'Banana', 'Cherry'];

  // ✅ The key is the only handle to insertItem / removeItem.
  final GlobalKey<AnimatedListState> _listKey = GlobalKey<AnimatedListState>();

  int _counter = 3;

  void _add() {
    final index = _items.length; // append at the end
    _items.insert(index, 'Item ${++_counter}'); // ✅ mutate data first
    _listKey.currentState!.insertItem(
      index,
      duration: const Duration(milliseconds: 350),
    ); // ✅ then animate the same index in
  }

  void _remove(int index) {
    // ✅ Capture the value BEFORE mutating, so the exit builder shows the
    // item that actually left — not the one that shifts into its place.
    final removed = _items.removeAt(index);
    _listKey.currentState!.removeItem(
      index,
      (context, animation) => _tile(removed, animation, onTap: null),
      duration: const Duration(milliseconds: 300),
    );
  }

  // Shared row, reused for both the live list and the exit animation.
  Widget _tile(
    String label,
    Animation<double> animation, {
    required VoidCallback? onTap,
  }) {
    return SizeTransition(
      sizeFactor: animation,
      child: FadeTransition(
        opacity: animation,
        child: Card(
          margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
          child: ListTile(
            leading: const Icon(Icons.drag_handle),
            title: Text(label),
            trailing: onTap == null ? null : const Icon(Icons.close),
            onTap: onTap,
          ),
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('AnimatedList insert & remove')),
      floatingActionButton: FloatingActionButton(
        onPressed: _add,
        child: const Icon(Icons.add),
      ),
      body: AnimatedList(
        key: _listKey,
        // ✅ Must equal the backing list length on first build.
        initialItemCount: _items.length,
        padding: const EdgeInsets.symmetric(vertical: 8),
        itemBuilder: (context, index, animation) =>
            _tile(_items[index], animation, onTap: () => _remove(index)),
      ),
    );
  }
}

⚠️ Pitfalls (4)

AnimatedList only animates when you drive it through AnimatedListState; the widget itself has no add/remove API.
✅ Hold a GlobalKey<AnimatedListState> and call _key.currentState!.insertItem(index) / removeItem(index, builder) to trigger the transitions.
official-docs · source
removeItem's builder receives an index-free callback, so reading the backing list inside it renders the wrong (shifted) item.
✅ Capture the removed value before mutating the list, then close over it in the removeItem builder so the exit animation shows the item that actually left.
official-docs · source
Mutating the backing list and the AnimatedList out of step desynchronizes indices and throws RangeError or animates the wrong row.
✅ Keep them in lockstep — for inserts call list.insert then insertItem with the same index; for removes call removeItem first (with the captured item) then list.removeAt.
community-consensus
initialItemCount that differs from the backing list length leaves the list rendering too few or too many rows on first build.
✅ Set initialItemCount: _items.length so AnimatedList and the data start in agreement.
official-docs · source

Official docs