Flutter Animated ListView Tutorial (AnimatedList): Smooth Insert, Remove, and Reorder

Learn Flutter AnimatedList and ListView animations: setup, insert/remove, custom transitions, slivers, reordering, performance tips, and complete code.

ASOasis
7 min read
Flutter Animated ListView Tutorial (AnimatedList): Smooth Insert, Remove, and Reorder

Image used for representation purposes only.

Overview

Flutter makes it easy to add delightful motion to lists—where most of your app’s content actually lives. In this tutorial, you’ll learn how to use AnimatedList and SliverAnimatedList to animate insertions and removals, craft custom transitions, avoid common pitfalls, and keep performance silky-smooth. You’ll also see ways to animate updates, handle reordering, and integrate with slivers.

What you’ll build

  • A minimal AnimatedList that inserts and removes items with smooth size, fade, and slide transitions.
  • A reusable animated item builder you can drop into any project.
  • A SliverAnimatedList inside a CustomScrollView.
  • Patterns for animating content updates and supporting reorders.

Prerequisites

  • Flutter installed and a recent stable channel.
  • Familiarity with StatefulWidget, ListView basics, and animations (Animation, Tween, CurvedAnimation).

Project setup

Create a fresh app:

flutter create animated_list_tutorial
cd animated_list_tutorial
flutter run

No extra dependencies required—AnimatedList ships with Flutter.

AnimatedList in a nutshell

AnimatedList animates items as they’re inserted or removed from a list. Key concepts:

  • AnimatedList requires a GlobalKey (or AnimatedList.of(context)) to call insertItem and removeItem.
  • itemBuilder: Widget Function(BuildContext, int index, Animation animation) builds each row and applies the provided animation to your transitions.
  • initialItemCount: the number of items present on first build.
  • insertItem(index) plays the animation forward (0.0 → 1.0) for the new item.
  • removeItem(index, builder) plays the animation in reverse (1.0 → 0.0) using a special builder to render the removed item during its exit.

Minimal, production-ready example

Below is a focused, complete example you can paste into lib/main.dart. It demonstrates:

  • Inserting at the end.
  • Removing at a tapped index.
  • A polished transition: size + fade + slide.
import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'AnimatedList Tutorial',
      theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.indigo),
      home: const AnimatedListDemo(),
    );
  }
}

class AnimatedListDemo extends StatefulWidget {
  const AnimatedListDemo({super.key});

  @override
  State<AnimatedListDemo> createState() => _AnimatedListDemoState();
}

class _AnimatedListDemoState extends State<AnimatedListDemo> {
  final GlobalKey<AnimatedListState> _listKey = GlobalKey<AnimatedListState>();
  final List<int> _items = List<int>.generate(5, (i) => i);
  int _nextItem = 5; // tracks the next item value

  void _insertItem() {
    final index = _items.length;
    _items.add(_nextItem++);
    _listKey.currentState!.insertItem(index, duration: const Duration(milliseconds: 300));
  }

  void _removeItem(int index) {
    // Keep the data to build the disappearing widget.
    final removed = _items.removeAt(index);
    _listKey.currentState!.removeItem(
      index,
      (context, animation) => _buildItem(context, removed, animation, removedItem: true),
      duration: const Duration(milliseconds: 300),
    );
  }

  Widget _buildItem(BuildContext context, int item, Animation<double> animation, {bool removedItem = false}) {
    // Combine size, fade and slide for a refined motion profile.
    final curved = CurvedAnimation(parent: animation, curve: Curves.easeInOut);

    return SizeTransition(
      sizeFactor: curved,
      axisAlignment: -1.0,
      child: FadeTransition(
        opacity: curved,
        child: SlideTransition(
          position: Tween<Offset>(begin: const Offset(0.05, 0), end: Offset.zero).animate(curved),
          child: Card(
            key: ValueKey('item-$item'),
            margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
            child: ListTile(
              leading: CircleAvatar(child: Text('$item')),
              title: Text('Item $item'),
              subtitle: const Text('Tap the trash icon to remove'),
              trailing: IconButton(
                icon: const Icon(Icons.delete_outline),
                onPressed: removedItem ? null : () {
                  final index = _items.indexOf(item);
                  if (index >= 0) _removeItem(index);
                },
              ),
            ),
          ),
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('AnimatedList: Insert & Remove')),
      body: AnimatedList(
        key: _listKey,
        initialItemCount: _items.length,
        itemBuilder: (context, index, animation) => _buildItem(context, _items[index], animation),
      ),
      floatingActionButton: FloatingActionButton.extended(
        onPressed: _insertItem,
        label: const Text('Add item'),
        icon: const Icon(Icons.add),
      ),
    );
  }
}

Why this pattern works

  • Data-first: The List _items is the single source of truth. UI reflects it.
  • Removal safety: When removing, you cache the removed value and build the disappearing widget with it, even after it leaves the list.
  • Stable identity: ValueKey ties UI identity to item value to avoid jank during rapid changes.

Custom transitions you can plug in

Try these alternatives inside _buildItem:

  • Scale + fade:
ScaleTransition(
  scale: CurvedAnimation(parent: animation, curve: Curves.easeOutBack),
  child: FadeTransition(opacity: animation, child: child),
)
  • Slide from bottom:
SlideTransition(
  position: Tween<Offset>(begin: const Offset(0, 0.2), end: Offset.zero)
      .chain(CurveTween(curve: Curves.easeOutCubic))
      .animate(animation),
  child: child,
)
  • Clip-reveal (height): SizeTransition is already doing this; you can also animate alignment or padding for a softer feel.

Tip: Always apply animation to a lightweight wrapper (SizeTransition, FadeTransition) and keep child layout stable to avoid re-layout thrash.

Insert at arbitrary positions

AnimatedList supports inserting anywhere—not just the end.

void insertAt(int index, int value) {
  _items.insert(index, value);
  _listKey.currentState!.insertItem(index, duration: const Duration(milliseconds: 250));
}

For UX, decide whether to scroll the inserted item into view after insertion.

Scrollable.ensureVisible(
  context,
  alignment: 0.5,
  duration: const Duration(milliseconds: 250),
);

Remove with a custom builder

removeItem gets a builder that renders the departing widget using the reverse animation. Never read _items[index] inside that builder—by then the list has already shifted.

_listKey.currentState!.removeItem(
  index,
  (context, anim) => _buildItem(context, removedValue, anim, removedItem: true),
);

Animate content updates (not just add/remove)

AnimatedList doesn’t animate “changes in place” by itself. For value updates in a row, animate inside the item:

class AnimatedCounter extends StatelessWidget {
  final int value;
  const AnimatedCounter(this.value, {super.key});

  @override
  Widget build(BuildContext context) {
    return AnimatedSwitcher(
      duration: const Duration(milliseconds: 200),
      transitionBuilder: (child, anim) => FadeTransition(opacity: anim, child: child),
      child: Text('$value', key: ValueKey(value), style: Theme.of(context).textTheme.titleMedium),
    );
  }
}

Use AnimatedSwitcher, TweenAnimationBuilder, or implicit animations (AnimatedContainer, AnimatedOpacity) to convey updates without touching the list structure.

Reordering items with animation

AnimatedList doesn’t handle “moves” out of the box. You have options:

  • ReorderableListView: Built-in drag-and-drop with subtle animations.
  • Third-party packages: Libraries such as implicitly_animated_reorderable_list or implicitly_animated_list handle diffs and reorders with expressive animations.

Example with ReorderableListView:

class ReorderableDemo extends StatefulWidget {
  const ReorderableDemo({super.key});
  @override
  State<ReorderableDemo> createState() => _ReorderableDemoState();
}

class _ReorderableDemoState extends State<ReorderableDemo> {
  final items = List<String>.generate(8, (i) => 'Tile ${i + 1}');

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('ReorderableListView')),
      body: ReorderableListView(
        children: [
          for (final item in items)
            ListTile(key: ValueKey(item), title: Text(item), leading: const Icon(Icons.drag_handle)),
        ],
        onReorder: (oldIndex, newIndex) {
          if (newIndex > oldIndex) newIndex -= 1;
          setState(() => items.insert(newIndex, items.removeAt(oldIndex)));
        },
      ),
    );
  }
}

SliverAnimatedList inside CustomScrollView

If you’re building a scrollable page with slivers (collapsing app bars, sticky headers, grids), use SliverAnimatedList.

class SliverAnimatedListPage extends StatefulWidget {
  const SliverAnimatedListPage({super.key});
  @override
  State<SliverAnimatedListPage> createState() => _SliverAnimatedListPageState();
}

class _SliverAnimatedListPageState extends State<SliverAnimatedListPage> {
  final GlobalKey<SliverAnimatedListState> _sliverKey = GlobalKey<SliverAnimatedListState>();
  final items = <String>['Alpha', 'Bravo', 'Charlie'];

  void addItem() {
    final index = items.length;
    items.add('Item ${index + 1}');
    _sliverKey.currentState!.insertItem(index);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: CustomScrollView(
        slivers: [
          const SliverAppBar(title: Text('SliverAnimatedList'), floating: true),
          SliverAnimatedList(
            key: _sliverKey,
            initialItemCount: items.length,
            itemBuilder: (context, index, animation) {
              final item = items[index];
              return SizeTransition(
                sizeFactor: animation,
                child: ListTile(title: Text(item)),
              );
            },
          ),
        ],
      ),
      floatingActionButton: FloatingActionButton(onPressed: addItem, child: const Icon(Icons.add)),
    );
  }
}

Architecture tips: keep state tidy

  • Single source of truth: Keep the backing list in your state management layer (StatefulWidget, Provider, Riverpod, BLoC). UI only reflects it.
  • Encapsulate mutations: Provide small helper methods (insertAt, removeAt) that update both data and AnimatedList state together.
  • Keys: Use stable keys (ValueKey with a unique id) for list rows, especially if they contain stateful children.

Performance checklist

  • Lightweight transitions: Prefer SizeTransition, FadeTransition, SlideTransition over expensive transforms when possible.
  • Avoid heavy rebuilds: Extract rows into separate widgets. Mark static subtrees const.
  • Cache images/icons: Use const Icons, AssetImage, or precache large images.
  • itemExtent: If all rows have a fixed height, prefer ListView with itemExtent. For AnimatedList, keep row layout stable so the size animation is smooth.
  • Repaint boundaries: Cards/ListTiles are typically fine. If you do heavy painting, consider wrapping complex child subtrees with RepaintBoundary.
  • Keep animations short: 200–350 ms feels responsive for list operations.
  • Avoid layout jank: Don’t trigger a rebuild of the entire list when changing a single row—keep updates local.

Accessibility and motion considerations

  • Touch targets: Keep row height ≥ 48 px and maintain contrast ratios.
  • Semantics: Provide semantic labels for action icons (e.g., delete) and ensure TalkBack/VoiceOver announces changes.
  • Reduce motion: Respect platform “reduce motion” settings—swap slide/scale for simple fades when AccessibilityFeatures.disableAnimations is true.
final reduce = MediaQuery.of(context).disableAnimations;
final curve = reduce ? Curves.linear : Curves.easeOutCubic;
final duration = reduce ? const Duration(milliseconds: 0) : const Duration(milliseconds: 250);

Common pitfalls (and fixes)

  • Reading shifted indices after removal: Always cache the removed item before calling removeItem; don’t reference _items[index] inside the removal builder.
  • Forgetting initialItemCount: If your list starts non-empty and you set 0, items won’t appear.
  • GlobalKey reuse: Don’t reuse a key between different AnimatedList instances; each list needs its own key/state.
  • Animating updates: AnimatedList won’t animate “content changed” by itself—animate inside rows (AnimatedSwitcher, TweenAnimationBuilder).
  • Inserting while scrolled: Consider auto-scrolling to reveal the new item so it doesn’t appear off-screen without context.

When to use which list widget

  • Use AnimatedList: When you need animated insertions/removals with fine control.
  • Use ReorderableListView: When drag-and-drop reordering is the primary interaction.
  • Use SliverAnimatedList: When composing with other slivers for advanced scroll effects.
  • Use implicit animations inside rows: For subtle content changes without structural list operations.

Wrap-up

AnimatedList and SliverAnimatedList give you precise, high-performance control over list motion. Start with a clean data model, pair it with insertItem/removeItem, and layer tasteful transitions like SizeTransition + FadeTransition + SlideTransition. For reorders and content diffs, complement with ReorderableListView or implicit animations. With these patterns, your lists will feel responsive, intentional, and delightful—without sacrificing maintainability.

Related Posts