Flutter Motion Kit

可预览的 Flutter 动画 + 避坑指南 · 支持 Claude Code 一键复用

← 返回

列表项交错入场

分类 staggered · 难度 3/5 · 验证于 Flutter 3.32 / 2026-06

列表项依次「淡入 + 上滑」入场。用单个 AnimationController + Interval 错峰驱动, 比给每个 item 各开一个 controller 更省资源。

⏳ 在线预览未就绪:运行 node scripts/sync-gists.mjs 生成 gist 后即可内嵌。

代码

// ✅ 推荐:单个 controller + Interval 错峰,AnimatedBuilder 传 child,正确 dispose。
// 可直接粘进 DartPad (https://dartpad.dev) 运行。
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.teal),
        home: const _Demo(),
      );
}

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

// 单个控制器 → SingleTickerProviderStateMixin
class _DemoState extends State<_Demo> with SingleTickerProviderStateMixin {
  static const _items = ['Inbox', 'Drafts', 'Sent', 'Starred', 'Archive', 'Trash'];

  late final AnimationController _controller = AnimationController(
    vsync: this,
    duration: const Duration(milliseconds: 900),
  )..forward();

  @override
  void dispose() {
    _controller.dispose(); // ✅ 释放 Ticker,杜绝泄漏
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Staggered entrance')),
      body: ListView.builder(
        itemCount: _items.length,
        itemBuilder: (context, i) {
          // 每个 item 一段错峰区间,begin/end 都保证落在 [0,1]
          final start = (i / _items.length) * 0.6;
          final anim = CurvedAnimation(
            parent: _controller,
            curve: Interval(start, start + 0.4, curve: Curves.easeOut),
          );
          return _Entrance(
            animation: anim,
            // child 不随动画变化 → 作为 child 传入,避免每帧重建
            child: ListTile(
              leading: CircleAvatar(child: Text('${i + 1}')),
              title: Text(_items[i]),
            ),
          );
        },
      ),
    );
  }
}

class _Entrance extends StatelessWidget {
  const _Entrance({required this.animation, required this.child});
  final Animation<double> animation;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: animation,
      // ✅ 关键:复用传入的 child,builder 只做轻量的 Transform/Opacity
      child: child,
      builder: (context, child) => Opacity(
        opacity: animation.value,
        child: Transform.translate(
          offset: Offset(0, 24 * (1 - animation.value)),
          child: child,
        ),
      ),
    );
  }
}

⚠️ 坑(4)

AnimatedBuilder 的 builder 闭包里直接 new 子树,会导致每帧重建整棵子树。
✅ 把不随动画变化的内容作为 child 传入,builder 里复用该 child(只动 Transform/Opacity)。
official-docs · 出处
漏 dispose() 会泄漏 AnimationController 持有的 Ticker。
✅ 在 State.dispose() 里 _controller.dispose();单控制器用 SingleTickerProviderStateMixin。
official-docs · 出处 · 测试佐证:test/leak_test.dart
Interval 的 begin/end 必须落在 [0,1],越界不报错但行为错误。
✅ 错峰公式 begin = i * gap,end = begin + slice,确保 end <= 1。
official-docs · 出处
在 controller 的 listener 里 setState 会触发整个 widget 全量 rebuild。
✅ 用 AnimatedBuilder/FadeTransition 把重建范围收敛到动画子树。
official-docs · 出处

官方文档