Flutter:自定义AnimationController控制Hero动画及Overlay注入Widget问题
完全可以脱离Navigator,用自定义AnimationController实现类Hero的共享元素过渡动画。Hero动画的核心是共享元素的位置、大小、透明度等属性的平滑过渡,我们可以手动模拟这个逻辑,无需依赖Navigator的页面跳转机制。
核心实现思路:
- 通过
GlobalKey获取原Widget的初始位置和尺寸(可选,用于更精准的过渡计算) - 用
AnimationController驱动动画,配合Tween定义过渡的属性范围 - 借助
AnimatedBuilder、SlideTransition、ScaleTransition等组件实时更新Widget的显示状态
举个卡片从列表位置过渡到SliverAppBar的示例:
class CustomHeroDemo extends StatefulWidget { const CustomHeroDemo({super.key}); @override State<CustomHeroDemo> createState() => _CustomHeroDemoState(); } class _CustomHeroDemoState extends State<CustomHeroDemo> with SingleTickerProviderStateMixin { late AnimationController _animController; late Animation<double> _scaleAnim; late Animation<Offset> _offsetAnim; late Animation<double> _opacityAnim; bool _isAnimating = false; final GlobalKey _sourceCardKey = GlobalKey(); @override void initState() { super.initState(); _animController = AnimationController( duration: const Duration(milliseconds: 650), vsync: this, ); // 定义过渡动画的曲线与属性 _scaleAnim = Tween<double>(begin: 1.0, end: 0.25).animate( CurvedAnimation(parent: _animController, curve: Curves.easeInOutCubic), ); _offsetAnim = Tween<Offset>(begin: Offset.zero, end: const Offset(0, -1.3)).animate( CurvedAnimation(parent: _animController, curve: Curves.easeInOut), ); _opacityAnim = Tween<double>(begin: 1.0, end: 0.0).animate( CurvedAnimation(parent: _animController, curve: const Interval(0.6, 1.0)), ); } void _triggerTransition() { setState(() => _isAnimating = true); _animController.forward(); } void _resetTransition() { _animController.reverse(); Future.delayed(const Duration(milliseconds: 650), () { setState(() => _isAnimating = false); }); } @override void dispose() { _animController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( body: CustomScrollView( slivers: [ SliverAppBar( expandedHeight: 220, flexibleSpace: FlexibleSpaceBar( title: _isAnimating ? SlideTransition( position: _offsetAnim, child: ScaleTransition( scale: _scaleAnim, child: const Card( color: Colors.blueAccent, child: Padding( padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Text("过渡后的卡片", style: TextStyle(color: Colors.white)), ), ), ), ) : null, ), ), SliverList( delegate: SliverChildBuilderDelegate( (ctx, index) { if (index == 3) { return _isAnimating ? FadeTransition( opacity: _opacityAnim, child: Card( key: _sourceCardKey, child: const Padding( padding: EdgeInsets.all(16.0), child: Text("原位置卡片"), ), ), ) : GestureDetector( onTap: _triggerTransition, child: Card( key: _sourceCardKey, child: const Padding( padding: EdgeInsets.all(16.0), child: Text("点击触发过渡到AppBar"), ), ), ); } return ListTile(title: Text("列表项 $index")); }, childCount: 15, ), ), ], ), floatingActionButton: _isAnimating ? FloatingActionButton( onPressed: _resetTransition, child: const Icon(Icons.arrow_back), ) : null, ); } }
2. 将Widget注入Overlay并保留原状态/控制器
可以实现这个需求,核心是用**GlobalKey绑定Widget的State对象**,确保Widget从原渲染树移到Overlay时,状态、逻辑及控制器不丢失。
关键步骤:
- 用
GlobalKey声明需要保留状态的Widget,绑定其State - 通过
Overlay.of(context)获取Overlay上下文,创建OverlayEntry插入目标位置 - 原位置用占位Widget(如
SizedBox.shrink())避免重复渲染
举个带状态的计数器Widget移到Overlay顶部的示例:
class OverlayStatePreserveDemo extends StatefulWidget { const OverlayStatePreserveDemo({super.key}); @override State<OverlayStatePreserveDemo> createState() => _OverlayStatePreserveDemoState(); } class _OverlayStatePreserveDemoState extends State<OverlayStatePreserveDemo> { final GlobalKey<_CounterWidgetState> _counterKey = GlobalKey(); OverlayEntry? _overlayEntry; void _moveToOverlay() { if (_overlayEntry != null) return; _overlayEntry = OverlayEntry( builder: (ctx) => Positioned( top: MediaQuery.of(ctx).padding.top + 12, left: 12, right: 12, child: Card( elevation: 6, child: CounterWidget(key: _counterKey), ), ), ); Overlay.of(context).insert(_overlayEntry!); setState(() {}); } void _moveBackToOriginal() { _overlayEntry?.remove(); _overlayEntry = null; setState(() {}); } @override void dispose() { _overlayEntry?.remove(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text("Overlay状态保留")), body: ListView( padding: const EdgeInsets.all(16), children: [ // 原位置:已移到Overlay则显示占位,否则显示计数器 _overlayEntry == null ? CounterWidget(key: _counterKey) : const SizedBox.shrink(), const SizedBox(height: 20), ElevatedButton( onPressed: _moveToOverlay, child: const Text("移到顶部Overlay"), ), const SizedBox(height: 10), ElevatedButton( onPressed: _overlayEntry != null ? _moveBackToOriginal : null, child: const Text("移回原位置"), ), ...List.generate(8, (index) => ListTile(title: Text("列表项 $index"))), ], ), ); } } // 带状态的计数器Widget class CounterWidget extends StatefulWidget { const CounterWidget({super.key}); @override State<CounterWidget> createState() => _CounterWidgetState(); } class _CounterWidgetState extends State<CounterWidget> { int _count = 0; void _incrementCount() { setState(() => _count++); } @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Text("当前计数:$_count", style: const TextStyle(fontSize: 18)), const SizedBox(width: 20), ElevatedButton(onPressed: _incrementCount, child: const Icon(Icons.add)), ], ), ); } }
结合需求:SliverAppBar的页面式过渡
将上述两种技术结合,即可实现“不跳转页面,让SliverAppBar呈现新页面进入的过渡”:
- 监听用户交互触发自定义
AnimationController,控制原Widget的隐藏动画 - 通过
OverlayEntry将目标Widget插入SliverAppBar区域,同时用GlobalKey保留其原状态 - 用动画控制器驱动Overlay中Widget的位置、大小过渡,模拟页面跳转的视觉效果
内容的提问来源于stack exchange,提问作者Walrus
相关产品推荐
相关产品推荐

