{"generatedAt":null,"count":6,"categories":["explicit","hero","implicit","staggered"],"entries":[{"id":"animated-container-implicit","title":"AnimatedContainer 隐式动画","category":"implicit","difficulty":1,"tags":["implicit","container","beginner","tap"],"summary":"点击时让方块的尺寸 / 颜色 / 圆角平滑过渡。能用隐式动画解决的，就不要手写 AnimationController —— 更短、更不容易出错。\n","verifiedOn":"Flutter 3.32 / 2026-06","gistId":"","docs":["https://docs.flutter.dev/ui/animations/implicit-animations","https://api.flutter.dev/flutter/widgets/AnimatedContainer-class.html"],"pitfalls":[{"claim":"只有「可补间(Tween)的属性」才会动画；改 child 的类型或 key 不会触发过渡。","fix":"用 AnimatedSwitcher 处理「子组件整体替换」，AnimatedContainer 只管自身属性。","source":"https://api.flutter.dev/flutter/widgets/AnimatedContainer-class.html","confidence":"official-docs"},{"claim":"首帧就给目标值不会有动画 —— 隐式动画只在「属性发生变化」时播放。","fix":"初始用起始值，在 build 之后（如点击/initState 后 setState）再改成目标值。","source":"https://docs.flutter.dev/ui/animations/implicit-animations","confidence":"official-docs"},{"claim":"为了这种简单过渡手写 AnimationController，是过度设计且容易漏 dispose。","fix":"简单属性过渡一律优先隐式动画族（AnimatedContainer/AnimatedOpacity/...）。","source":"author-experience","confidence":"author-experience"}],"code":"// ✅ 推荐：用隐式动画做简单属性过渡，零 controller、零 dispose 负担。\n// 可直接粘进 DartPad (https://dartpad.dev) 运行。\nimport 'package:flutter/material.dart';\n\nvoid main() => runApp(const _App());\n\nclass _App extends StatelessWidget {\n  const _App();\n\n  @override\n  Widget build(BuildContext context) {\n    return MaterialApp(\n      debugShowCheckedModeBanner: false,\n      theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.indigo),\n      home: const _Demo(),\n    );\n  }\n}\n\nclass _Demo extends StatefulWidget {\n  const _Demo();\n\n  @override\n  State<_Demo> createState() => _DemoState();\n}\n\nclass _DemoState extends State<_Demo> {\n  bool _expanded = false;\n\n  @override\n  Widget build(BuildContext context) {\n    return Scaffold(\n      body: Center(\n        child: GestureDetector(\n          onTap: () => setState(() => _expanded = !_expanded),\n          child: AnimatedContainer(\n            // 关键：只声明「目标状态」，框架负责补间过渡。\n            duration: const Duration(milliseconds: 400),\n            curve: Curves.easeInOutCubic,\n            width: _expanded ? 240 : 120,\n            height: _expanded ? 240 : 120,\n            decoration: BoxDecoration(\n              color: _expanded ? Colors.indigo : Colors.indigo.shade200,\n              borderRadius: BorderRadius.circular(_expanded ? 32 : 12),\n            ),\n            alignment: Alignment.center,\n            child: const Text('Tap me', style: TextStyle(color: Colors.white)),\n          ),\n        ),\n      ),\n    );\n  }\n}\n","badCode":"// ❌ 反面教材：为了一个简单过渡手写 AnimationController。\n// 问题：① 代码量翻倍 ② 极易漏 dispose 造成 ticker 泄漏（此处故意漏）\n//      ③ 用 setState 监听 = 每帧整体重建。隐式动画一行就能替代这一切。\nimport 'package:flutter/material.dart';\n\nvoid main() => runApp(const MaterialApp(home: _Demo()));\n\nclass _Demo extends StatefulWidget {\n  const _Demo();\n  @override\n  State<_Demo> createState() => _DemoState();\n}\n\nclass _DemoState extends State<_Demo> with SingleTickerProviderStateMixin {\n  late final AnimationController _c = AnimationController(\n    vsync: this,\n    duration: const Duration(milliseconds: 400),\n  )..addListener(() => setState(() {})); // ❌ 每帧 setState\n\n  // ❌ 故意没有 dispose() —— 真实项目里这就是一处 ticker / 内存泄漏\n\n  @override\n  Widget build(BuildContext context) {\n    final t = Curves.easeInOutCubic.transform(_c.value);\n    return Scaffold(\n      body: Center(\n        child: GestureDetector(\n          onTap: () => _c.isCompleted ? _c.reverse() : _c.forward(),\n          child: Container(\n            width: 120 + 120 * t,\n            height: 120 + 120 * t,\n            decoration: BoxDecoration(\n              color: Color.lerp(Colors.indigo.shade200, Colors.indigo, t),\n              borderRadius: BorderRadius.circular(12 + 20 * t),\n            ),\n          ),\n        ),\n      ),\n    );\n  }\n}\n","dartpadUrl":null},{"id":"animated-switcher-swap","title":"AnimatedSwitcher 内容切换","category":"implicit","difficulty":2,"tags":["switcher","fade","scale","state","key"],"summary":"在新旧内容之间做淡入缩放过渡（如计数器、加载/成功状态切换）。关键是给子组件唯一 Key。\n","verifiedOn":"Flutter 3.32 / 2026-06","gistId":"","docs":["https://api.flutter.dev/flutter/widgets/AnimatedSwitcher-class.html"],"pitfalls":[{"claim":"子组件不带唯一 Key 时，AnimatedSwitcher 判定为「同一个 widget」，根本不触发过渡。","fix":"给 child 设 ValueKey（用能区分新旧内容的值），内容变化才会被识别。","source":"https://api.flutter.dev/flutter/widgets/AnimatedSwitcher-class.html","confidence":"official-docs"},{"claim":"它只对「child 整体替换」生效，对 child 内部状态变化无效。","fix":"状态变化要体现成不同的 child（或不同 Key）。","source":"https://api.flutter.dev/flutter/widgets/AnimatedSwitcher/transitionBuilder.html","confidence":"official-docs"},{"claim":"transitionBuilder 同时作用于进入和离开，离开用的是上一个 child。","fix":"组合 FadeTransition/ScaleTransition 时确保对两端都成立，避免离开态错位。","source":"https://api.flutter.dev/flutter/widgets/AnimatedSwitcher/transitionBuilder.html","confidence":"official-docs"}],"code":"// ✅ 推荐：给 child 唯一 ValueKey，AnimatedSwitcher 才能识别「内容变了」并过渡。\n// 可直接粘进 DartPad (https://dartpad.dev) 运行。\nimport 'package:flutter/material.dart';\n\nvoid main() => runApp(const _App());\n\nclass _App extends StatelessWidget {\n  const _App();\n  @override\n  Widget build(BuildContext context) => const MaterialApp(\n        debugShowCheckedModeBanner: false,\n        home: _Demo(),\n      );\n}\n\nclass _Demo extends StatefulWidget {\n  const _Demo();\n  @override\n  State<_Demo> createState() => _DemoState();\n}\n\nclass _DemoState extends State<_Demo> {\n  int _count = 0;\n\n  @override\n  Widget build(BuildContext context) {\n    return Scaffold(\n      floatingActionButton: FloatingActionButton(\n        onPressed: () => setState(() => _count++),\n        child: const Icon(Icons.add),\n      ),\n      body: Center(\n        child: AnimatedSwitcher(\n          duration: const Duration(milliseconds: 350),\n          transitionBuilder: (child, animation) => ScaleTransition(\n            scale: animation,\n            child: FadeTransition(opacity: animation, child: child),\n          ),\n          child: Text(\n            '$_count',\n            // 关键：用 ValueKey 标识不同内容，否则不会有过渡\n            key: ValueKey<int>(_count),\n            style: const TextStyle(fontSize: 96, fontWeight: FontWeight.bold),\n          ),\n        ),\n      ),\n    );\n  }\n}\n","badCode":"// ❌ 反面教材：child 不带 Key。\n// AnimatedSwitcher 认为前后是同一个 Text widget，只是参数变了 ->\n// 数字会瞬间跳变，完全没有过渡动画。\nimport 'package:flutter/material.dart';\n\nvoid main() => runApp(const MaterialApp(home: _Demo()));\n\nclass _Demo extends StatefulWidget {\n  const _Demo();\n  @override\n  State<_Demo> createState() => _DemoState();\n}\n\nclass _DemoState extends State<_Demo> {\n  int _count = 0;\n  @override\n  Widget build(BuildContext context) {\n    return Scaffold(\n      floatingActionButton: FloatingActionButton(\n        onPressed: () => setState(() => _count++),\n        child: const Icon(Icons.add),\n      ),\n      body: Center(\n        child: AnimatedSwitcher(\n          duration: const Duration(milliseconds: 350),\n          child: Text(\n            '$_count', // ❌ 没有 key -> 不触发切换动画\n            style: const TextStyle(fontSize: 96, fontWeight: FontWeight.bold),\n          ),\n        ),\n      ),\n    );\n  }\n}\n","dartpadUrl":null},{"id":"hero-shared-element","title":"Hero 共享元素转场","category":"hero","difficulty":2,"tags":["hero","navigation","shared-element","transition"],"summary":"点击网格项，元素「飞」到详情页对应位置。两端用同一个 Hero tag，框架自动接管过渡。\n","verifiedOn":"Flutter 3.32 / 2026-06","gistId":"","docs":["https://docs.flutter.dev/ui/animations/hero-animations","https://api.flutter.dev/flutter/widgets/Hero-class.html"],"pitfalls":[{"claim":"同一屏幕同时出现两个相同 tag 的 Hero 会直接抛异常。","fix":"tag 必须全局唯一（用 id 拼接，如 'box-$i'），同屏不可重复。","source":"https://api.flutter.dev/flutter/widgets/Hero-class.html","confidence":"official-docs"},{"claim":"嵌套 Navigator（如 BottomNavigationBar 内）里的 Hero 不会跨外层路由飞行。","fix":"确保起止页在同一个 Navigator；或用 flightShuttleBuilder 自定义飞行组件。","source":"https://api.flutter.dev/flutter/widgets/Hero/flightShuttleBuilder.html","confidence":"official-docs"},{"claim":"首尾尺寸/圆角差异大时，默认矩形插值可能出现突变或裁切。","fix":"用 createRectTween 提供更平滑的飞行轨迹，子组件保持一致的 BoxFit/形状。","source":"https://api.flutter.dev/flutter/widgets/Hero/createRectTween.html","confidence":"official-docs"},{"claim":"Hero 飞行期间原位置会留白。","fix":"需要占位时用 placeholderBuilder 填充。","source":"https://api.flutter.dev/flutter/widgets/Hero/placeholderBuilder.html","confidence":"official-docs"}],"code":"// ✅ 推荐：列表页与详情页用同一个唯一 tag，框架自动接管共享元素飞行。\n// 可直接粘进 DartPad (https://dartpad.dev) 运行。\nimport 'package:flutter/material.dart';\n\nvoid main() => runApp(const _App());\n\nclass _App extends StatelessWidget {\n  const _App();\n  @override\n  Widget build(BuildContext context) => MaterialApp(\n        debugShowCheckedModeBanner: false,\n        theme: ThemeData(useMaterial3: true),\n        home: const _GridPage(),\n      );\n}\n\nconst _colors = [\n  Colors.red, Colors.green, Colors.blue,\n  Colors.orange, Colors.purple, Colors.teal,\n];\n\nclass _GridPage extends StatelessWidget {\n  const _GridPage();\n  @override\n  Widget build(BuildContext context) {\n    return Scaffold(\n      appBar: AppBar(title: const Text('Hero gallery')),\n      body: GridView.count(\n        crossAxisCount: 3,\n        padding: const EdgeInsets.all(12),\n        mainAxisSpacing: 12,\n        crossAxisSpacing: 12,\n        children: [\n          for (final (i, c) in _colors.indexed)\n            GestureDetector(\n              onTap: () => Navigator.of(context).push(\n                MaterialPageRoute(builder: (_) => _DetailPage(index: i, color: c)),\n              ),\n              child: Hero(\n                tag: 'box-$i', // 唯一 tag，与详情页一致\n                child: Container(\n                  decoration: BoxDecoration(\n                    color: c,\n                    borderRadius: BorderRadius.circular(12),\n                  ),\n                ),\n              ),\n            ),\n        ],\n      ),\n    );\n  }\n}\n\nclass _DetailPage extends StatelessWidget {\n  const _DetailPage({required this.index, required this.color});\n  final int index;\n  final Color color;\n\n  @override\n  Widget build(BuildContext context) {\n    return Scaffold(\n      appBar: AppBar(),\n      body: Center(\n        child: Hero(\n          tag: 'box-$index', // 与列表页同一个 tag\n          child: Container(\n            width: 260,\n            height: 260,\n            decoration: BoxDecoration(\n              color: color,\n              borderRadius: BorderRadius.circular(24),\n            ),\n          ),\n        ),\n      ),\n    );\n  }\n}\n","badCode":"// ❌ 反面教材：所有项用同一个常量 tag。\n// 同屏出现多个相同 tag 的 Hero —— 运行时直接抛 \"There are multiple heroes\n// that share the same tag within a subtree.\"\nimport 'package:flutter/material.dart';\n\nvoid main() => runApp(const MaterialApp(home: _GridPage()));\n\nconst _colors = [Colors.red, Colors.green, Colors.blue, Colors.orange];\n\nclass _GridPage extends StatelessWidget {\n  const _GridPage();\n  @override\n  Widget build(BuildContext context) {\n    return Scaffold(\n      body: GridView.count(\n        crossAxisCount: 2,\n        children: [\n          for (final c in _colors)\n            const Hero(\n              tag: 'box', // ❌ 所有项共用同一个 tag -> 同屏冲突崩溃\n              child: Placeholder(),\n            ),\n        ],\n      ),\n    );\n  }\n}\n","dartpadUrl":null},{"id":"page-route-transition","title":"自定义页面转场（淡入+上滑）","category":"explicit","difficulty":2,"tags":["navigation","route","transition","page"],"summary":"用 PageRouteBuilder 自定义进场动画（淡入 + 轻微上滑），替代默认平台转场。\n","verifiedOn":"Flutter 3.32 / 2026-06","gistId":"","docs":["https://api.flutter.dev/flutter/widgets/PageRouteBuilder-class.html","https://docs.flutter.dev/cookbook/animation/page-route-animation"],"pitfalls":[{"claim":"直接用线性 animation 做 Tween，过渡很生硬。","fix":"用 CurvedAnimation 包一层（如 easeOutCubic）再驱动 Fade/Slide。","source":"https://docs.flutter.dev/cookbook/animation/page-route-animation","confidence":"official-docs"},{"claim":"只设 transitionDuration、忘了 reverseTransitionDuration，返回过渡时长不一致。","fix":"两个时长都显式设置。","source":"https://api.flutter.dev/flutter/widgets/PageRouteBuilder/reverseTransitionDuration.html","confidence":"official-docs"},{"claim":"在 transitionsBuilder 里重建大子树，而非使用传入的 child，会每帧重建页面。","fix":"始终复用回调里的 child 参数。","source":"https://api.flutter.dev/flutter/widgets/PageRouteBuilder/transitionsBuilder.html","confidence":"official-docs"},{"claim":"想全 App 统一转场却在每处手写，难维护且不一致。","fix":"用 ThemeData.pageTransitionsTheme 配 PageTransitionsBuilder 统一覆盖。","source":"https://api.flutter.dev/flutter/material/PageTransitionsTheme-class.html","confidence":"official-docs"}],"code":"// ✅ 推荐：PageRouteBuilder + CurvedAnimation，淡入 + 上滑，进/出时长都显式设置。\n// 可直接粘进 DartPad (https://dartpad.dev) 运行。\nimport 'package:flutter/material.dart';\n\nvoid main() => runApp(const _App());\n\nclass _App extends StatelessWidget {\n  const _App();\n  @override\n  Widget build(BuildContext context) => const MaterialApp(\n        debugShowCheckedModeBanner: false,\n        home: _HomePage(),\n      );\n}\n\n// 可复用的转场：淡入 + 轻微上滑\nRoute<T> fadeSlideRoute<T>(Widget page) {\n  return PageRouteBuilder<T>(\n    transitionDuration: const Duration(milliseconds: 350),\n    reverseTransitionDuration: const Duration(milliseconds: 300),\n    pageBuilder: (context, animation, secondaryAnimation) => page,\n    transitionsBuilder: (context, animation, secondaryAnimation, child) {\n      final curved = CurvedAnimation(parent: animation, curve: Curves.easeOutCubic);\n      return FadeTransition(\n        opacity: curved,\n        child: SlideTransition(\n          position: Tween<Offset>(\n            begin: const Offset(0, 0.08),\n            end: Offset.zero,\n          ).animate(curved),\n          child: child, // 复用传入的 child\n        ),\n      );\n    },\n  );\n}\n\nclass _HomePage extends StatelessWidget {\n  const _HomePage();\n  @override\n  Widget build(BuildContext context) {\n    return Scaffold(\n      appBar: AppBar(title: const Text('Home')),\n      body: Center(\n        child: FilledButton(\n          onPressed: () => Navigator.of(context).push(fadeSlideRoute(const _SecondPage())),\n          child: const Text('Open with fade + slide'),\n        ),\n      ),\n    );\n  }\n}\n\nclass _SecondPage extends StatelessWidget {\n  const _SecondPage();\n  @override\n  Widget build(BuildContext context) {\n    return Scaffold(\n      appBar: AppBar(title: const Text('Second')),\n      body: const Center(child: Text('Hello 👋')),\n    );\n  }\n}\n","badCode":null,"dartpadUrl":null},{"id":"shimmer-skeleton","title":"Shimmer 骨架屏加载","category":"explicit","difficulty":3,"tags":["shimmer","skeleton","loading","shadermask","gradient"],"summary":"数据加载时用「扫光」骨架占位。单个 controller + ShaderMask 渐变扫过，比逐个 item 开控制器省。\n","verifiedOn":"Flutter 3.32 / 2026-06","gistId":"","docs":["https://api.flutter.dev/flutter/widgets/ShaderMask-class.html","https://api.flutter.dev/flutter/widgets/RepaintBoundary-class.html"],"pitfalls":[{"claim":"ShaderMask 扫光每帧重绘，不隔离会带动周边一起重绘，列表里尤其明显。","fix":"用 RepaintBoundary 包住每个骨架单元，把重绘范围钉在自身。","source":"https://api.flutter.dev/flutter/widgets/RepaintBoundary-class.html","confidence":"official-docs"},{"claim":"AnimatedBuilder 不传 child，骨架形状子树会每帧重建。","fix":"把骨架形状作为 child 传入，builder 里只更新渐变。","source":"https://api.flutter.dev/flutter/widgets/AnimatedBuilder-class.html","confidence":"official-docs"},{"claim":"漏 dispose() 会泄漏 controller 的 Ticker。","fix":"dispose() 里 _controller.dispose()。","source":"https://api.flutter.dev/flutter/widgets/SingleTickerProviderStateMixin-mixin.html","confidence":"official-docs"},{"claim":"repeat() 在页面切到后台/不可见时仍在空转，浪费帧。","fix":"加载完成即停止/移除；或用 TickerMode/可见性感知控制播放。","source":"https://api.flutter.dev/flutter/widgets/TickerMode-class.html","confidence":"official-docs"}],"code":"// ✅ 推荐：单 controller 驱动 ShaderMask 扫光，child 复用 + RepaintBoundary 隔离重绘。\n// 可直接粘进 DartPad (https://dartpad.dev) 运行。\nimport 'package:flutter/material.dart';\n\nvoid main() => runApp(const _App());\n\nclass _App extends StatelessWidget {\n  const _App();\n  @override\n  Widget build(BuildContext context) => MaterialApp(\n        debugShowCheckedModeBanner: false,\n        theme: ThemeData.dark(useMaterial3: true),\n        home: const _Demo(),\n      );\n}\n\nclass _Demo extends StatelessWidget {\n  const _Demo();\n  @override\n  Widget build(BuildContext context) {\n    return Scaffold(\n      appBar: AppBar(title: const Text('Loading…')),\n      body: ListView.builder(\n        padding: const EdgeInsets.all(16),\n        itemCount: 6,\n        // 每个骨架单元独立隔离重绘\n        itemBuilder: (_, __) => const RepaintBoundary(child: _SkeletonTile()),\n      ),\n    );\n  }\n}\n\nclass _SkeletonTile extends StatelessWidget {\n  const _SkeletonTile();\n  @override\n  Widget build(BuildContext context) {\n    return const Padding(\n      padding: EdgeInsets.symmetric(vertical: 10),\n      child: _Shimmer(\n        child: Row(\n          children: [\n            _Box(width: 56, height: 56, radius: 28),\n            SizedBox(width: 12),\n            Expanded(\n              child: Column(\n                crossAxisAlignment: CrossAxisAlignment.start,\n                children: [\n                  _Box(width: 160, height: 14, radius: 6),\n                  SizedBox(height: 10),\n                  _Box(width: 240, height: 12, radius: 6),\n                ],\n              ),\n            ),\n          ],\n        ),\n      ),\n    );\n  }\n}\n\nclass _Box extends StatelessWidget {\n  const _Box({required this.width, required this.height, required this.radius});\n  final double width, height, radius;\n  @override\n  Widget build(BuildContext context) => Container(\n        width: width,\n        height: height,\n        decoration: BoxDecoration(\n          color: Colors.white, // 颜色由 ShaderMask 接管\n          borderRadius: BorderRadius.circular(radius),\n        ),\n      );\n}\n\nclass _Shimmer extends StatefulWidget {\n  const _Shimmer({required this.child});\n  final Widget child;\n  @override\n  State<_Shimmer> createState() => _ShimmerState();\n}\n\nclass _ShimmerState extends State<_Shimmer> with SingleTickerProviderStateMixin {\n  late final AnimationController _controller = AnimationController(\n    vsync: this,\n    duration: const Duration(milliseconds: 1400),\n  )..repeat();\n\n  @override\n  void dispose() {\n    _controller.dispose();\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    return AnimatedBuilder(\n      animation: _controller,\n      child: widget.child, // 骨架形状不随帧变化 -> 作为 child 复用\n      builder: (context, child) {\n        final dx = _controller.value * 2 - 1; // -1 -> 1 扫过\n        return ShaderMask(\n          blendMode: BlendMode.srcATop,\n          shaderCallback: (rect) => LinearGradient(\n            begin: Alignment(-1 + dx, 0),\n            end: Alignment(1 + dx, 0),\n            colors: const [Color(0xFF2A2F37), Color(0xFF454C59), Color(0xFF2A2F37)],\n            stops: const [0.35, 0.5, 0.65],\n          ).createShader(rect),\n          child: child,\n        );\n      },\n    );\n  }\n}\n","badCode":null,"dartpadUrl":null},{"id":"staggered-list-entrance","title":"列表项交错入场","category":"staggered","difficulty":3,"tags":["list","staggered","entrance","AnimatedBuilder","controller"],"summary":"列表项依次「淡入 + 上滑」入场。用单个 AnimationController + Interval 错峰驱动， 比给每个 item 各开一个 controller 更省资源。\n","verifiedOn":"Flutter 3.32 / 2026-06","gistId":"","docs":["https://docs.flutter.dev/ui/animations/staggered-animations","https://api.flutter.dev/flutter/widgets/AnimatedBuilder-class.html","https://api.flutter.dev/flutter/animation/Interval-class.html"],"pitfalls":[{"claim":"AnimatedBuilder 的 builder 闭包里直接 new 子树，会导致每帧重建整棵子树。","fix":"把不随动画变化的内容作为 child 传入，builder 里复用该 child（只动 Transform/Opacity）。","source":"https://api.flutter.dev/flutter/widgets/AnimatedBuilder-class.html","confidence":"official-docs"},{"claim":"漏 dispose() 会泄漏 AnimationController 持有的 Ticker。","fix":"在 State.dispose() 里 _controller.dispose()；单控制器用 SingleTickerProviderStateMixin。","source":"https://api.flutter.dev/flutter/widgets/SingleTickerProviderStateMixin-mixin.html","confidence":"official-docs","provenBy":"test/leak_test.dart"},{"claim":"Interval 的 begin/end 必须落在 [0,1]，越界不报错但行为错误。","fix":"错峰公式 begin = i * gap，end = begin + slice，确保 end <= 1。","source":"https://api.flutter.dev/flutter/animation/Interval-class.html","confidence":"official-docs"},{"claim":"在 controller 的 listener 里 setState 会触发整个 widget 全量 rebuild。","fix":"用 AnimatedBuilder/FadeTransition 把重建范围收敛到动画子树。","source":"https://docs.flutter.dev/ui/animations/tutorial","confidence":"official-docs"}],"code":"// ✅ 推荐：单个 controller + Interval 错峰，AnimatedBuilder 传 child，正确 dispose。\n// 可直接粘进 DartPad (https://dartpad.dev) 运行。\nimport 'package:flutter/material.dart';\n\nvoid main() => runApp(const _App());\n\nclass _App extends StatelessWidget {\n  const _App();\n  @override\n  Widget build(BuildContext context) => MaterialApp(\n        debugShowCheckedModeBanner: false,\n        theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.teal),\n        home: const _Demo(),\n      );\n}\n\nclass _Demo extends StatefulWidget {\n  const _Demo();\n  @override\n  State<_Demo> createState() => _DemoState();\n}\n\n// 单个控制器 → SingleTickerProviderStateMixin\nclass _DemoState extends State<_Demo> with SingleTickerProviderStateMixin {\n  static const _items = ['Inbox', 'Drafts', 'Sent', 'Starred', 'Archive', 'Trash'];\n\n  late final AnimationController _controller = AnimationController(\n    vsync: this,\n    duration: const Duration(milliseconds: 900),\n  )..forward();\n\n  @override\n  void dispose() {\n    _controller.dispose(); // ✅ 释放 Ticker，杜绝泄漏\n    super.dispose();\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    return Scaffold(\n      appBar: AppBar(title: const Text('Staggered entrance')),\n      body: ListView.builder(\n        itemCount: _items.length,\n        itemBuilder: (context, i) {\n          // 每个 item 一段错峰区间，begin/end 都保证落在 [0,1]\n          final start = (i / _items.length) * 0.6;\n          final anim = CurvedAnimation(\n            parent: _controller,\n            curve: Interval(start, start + 0.4, curve: Curves.easeOut),\n          );\n          return _Entrance(\n            animation: anim,\n            // child 不随动画变化 → 作为 child 传入，避免每帧重建\n            child: ListTile(\n              leading: CircleAvatar(child: Text('${i + 1}')),\n              title: Text(_items[i]),\n            ),\n          );\n        },\n      ),\n    );\n  }\n}\n\nclass _Entrance extends StatelessWidget {\n  const _Entrance({required this.animation, required this.child});\n  final Animation<double> animation;\n  final Widget child;\n\n  @override\n  Widget build(BuildContext context) {\n    return AnimatedBuilder(\n      animation: animation,\n      // ✅ 关键：复用传入的 child，builder 只做轻量的 Transform/Opacity\n      child: child,\n      builder: (context, child) => Opacity(\n        opacity: animation.value,\n        child: Transform.translate(\n          offset: Offset(0, 24 * (1 - animation.value)),\n          child: child,\n        ),\n      ),\n    );\n  }\n}\n","badCode":"// ❌ 反面教材：listener 里 setState 全量重建 + AnimatedBuilder 不传 child + 漏 dispose。\nimport 'package:flutter/material.dart';\n\nvoid main() => runApp(const MaterialApp(home: _Demo()));\n\nclass _Demo extends StatefulWidget {\n  const _Demo();\n  @override\n  State<_Demo> createState() => _DemoState();\n}\n\nclass _DemoState extends State<_Demo> with SingleTickerProviderStateMixin {\n  static const _items = ['Inbox', 'Drafts', 'Sent', 'Starred', 'Archive', 'Trash'];\n\n  late final AnimationController _controller = AnimationController(\n    vsync: this,\n    duration: const Duration(milliseconds: 900),\n  )\n    ..addListener(() => setState(() {})) // ❌ 每帧 setState，整页重建\n    ..forward();\n\n  // ❌ 没有 dispose() —— Ticker 泄漏\n\n  @override\n  Widget build(BuildContext context) {\n    return Scaffold(\n      appBar: AppBar(title: const Text('BAD example')),\n      body: ListView.builder(\n        itemCount: _items.length,\n        itemBuilder: (context, i) {\n          final v = _controller.value;\n          // ❌ 子树（ListTile）在每帧都被重新构建\n          return Opacity(\n            opacity: v,\n            child: Transform.translate(\n              offset: Offset(0, 24 * (1 - v)),\n              child: ListTile(\n                leading: CircleAvatar(child: Text('${i + 1}')),\n                title: Text(_items[i]),\n              ),\n            ),\n          );\n        },\n      ),\n    );\n  }\n}\n","dartpadUrl":null}]}