Flutter:如何同时实现Hero转场与相邻组件自定义动画?
解决Flutter路由切换时同步Hero动画与相邻组件自定义动画的问题
嘿,我来帮你搞定这个场景!首先得明确核心问题:你的自定义标签导航和WidgetsApp是兄弟节点,它不在路由栈的页面层级里,默认情况下路由切换的通知不会自动传递给它,所以Hero动画跑起来的时候,标签导航的自定义动画没法同步触发。下面是一步步的解决方案:
核心思路
通过监听路由切换状态,把路由变化的信号传递给标签导航组件,让它和Hero动画同步执行。我们会用到NavigatorObserver监听路由事件,再通过你的自定义InheritedWidget把状态同步给所有需要的组件。
具体实现步骤
1. 扩展你的自定义InheritedWidget,加入路由状态
首先在你的全局状态管理InheritedWidget里添加路由切换相关的状态和回调,这样标签导航能感知到路由变化:
class AppGlobalState extends InheritedWidget { final bool isRouteTransitioning; // 标记是否正在路由切换 final VoidCallback? onTransitionStart; final VoidCallback? onTransitionEnd; const AppGlobalState({ super.key, required this.isRouteTransitioning, this.onTransitionStart, this.onTransitionEnd, required super.child, }); static AppGlobalState of(BuildContext context) { final result = context.dependOnInheritedWidgetOfExactType<AppGlobalState>(); assert(result != null, "找不到AppGlobalState,请确保组件在其范围内"); return result!; } @override bool updateShouldNotify(AppGlobalState oldWidget) { return isRouteTransitioning != oldWidget.isRouteTransitioning; } }
2. 添加自定义路由观察者,监听路由切换
创建一个NavigatorObserver的子类,用来监听路由的push/pop事件,以及动画的开始和结束:
class RouteTransitionObserver extends NavigatorObserver { final VoidCallback? onPushStart; final VoidCallback? onPushEnd; final VoidCallback? onPopStart; final VoidCallback? onPopEnd; RouteTransitionObserver({ this.onPushStart, this.onPushEnd, this.onPopStart, this.onPopEnd, }); @override void didPush(Route route, Route? previousRoute) { super.didPush(route, previousRoute); onPushStart?.call(); // 监听路由动画完成事件 if (route is PageRoute) { route.animation?.addStatusListener((status) { if (status == AnimationStatus.completed) onPushEnd?.call(); }); } } @override void didPop(Route route, Route? previousRoute) { super.didPop(route, previousRoute); onPopStart?.call(); if (route is PageRoute) { route.animation?.addStatusListener((status) { if (status == AnimationStatus.dismissed) onPopEnd?.call(); }); } } }
3. 在根StatefulWidget里绑定状态与观察者
在你的根组件中,管理路由切换状态,并把观察者添加到WidgetsApp的navigatorObservers里:
class AppRoot extends StatefulWidget { const AppRoot({super.key}); @override State<AppRoot> createState() => _AppRootState(); } class _AppRootState extends State<AppRoot> { bool _isTransitioning = false; late final RouteTransitionObserver _routeObserver; @override void initState() { super.initState(); _routeObserver = RouteTransitionObserver( onPushStart: () => _updateTransitionState(true), onPushEnd: () => _updateTransitionState(false), onPopStart: () => _updateTransitionState(true), onPopEnd: () => _updateTransitionState(false), ); } void _updateTransitionState(bool isTransitioning) { if (mounted) { setState(() => _isTransitioning = isTransitioning); } } @override Widget build(BuildContext context) { return AppGlobalState( isRouteTransitioning: _isTransitioning, child: Stack( children: [ WidgetsApp( navigatorObservers: [_routeObserver], // 添加自定义观察者 home: const HomePage(), // 其他WidgetsApp配置(比如color、theme等) ), const CustomTabNavigation(), // 你的自定义标签导航 ], ), ); } }
4. 给标签导航添加同步动画
在你的自定义标签导航组件里,通过AppGlobalState的状态触发动画,注意动画时长要和Hero默认的300ms保持一致:
class CustomTabNavigation extends StatefulWidget { const CustomTabNavigation({super.key}); @override State<CustomTabNavigation> createState() => _CustomTabNavigationState(); } class _CustomTabNavigationState extends State<CustomTabNavigation> with SingleTickerProviderStateMixin { late AnimationController _animController; late Animation<double> _scaleAnim; @override void initState() { super.initState(); _animController = AnimationController( duration: const Duration(milliseconds: 300), // 和Hero动画时长同步 vsync: this, ); _scaleAnim = Tween<double>(begin: 1.0, end: 0.9).animate(_animController); } @override void didChangeDependencies() { super.didChangeDependencies(); // 监听路由切换状态,触发动画 final globalState = AppGlobalState.of(context); if (globalState.isRouteTransitioning) { _animController.forward(); } else { _animController.reverse(); } } @override void dispose() { _animController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Positioned( bottom: 20, left: 20, right: 20, child: ScaleTransition( scale: _scaleAnim, child: Container( height: 60, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(30), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ IconButton(icon: const Icon(Icons.home), onPressed: () {}), IconButton(icon: const Icon(Icons.search), onPressed: () {}), IconButton(icon: const Icon(Icons.person), onPressed: () {}), ], ), ), ), ); } }
5. 确保Hero动画正常配置
最后,保证你的页面里Hero标签正确配对,比如首页和详情页的Hero使用同一个tag:
// 首页的Hero组件 Hero( tag: 'feature-card', child: GestureDetector( onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const DetailPage())), child: Container(width: 150, height: 150, color: Colors.orange), ), ) // 详情页的Hero组件 Hero( tag: 'feature-card', child: Container(width: 300, height: 300, color: Colors.orange), )
关键注意点
- 动画时长一定要和Hero默认的300ms对齐,这样两个动画才能完全同步;
- 如果需要更精细的同步(比如Hero动画的进度回调),可以通过
HeroController获取Hero的动画对象,但上面的方案已经能覆盖大部分场景; - 你的自定义
InheritedWidget如果已经有状态管理逻辑,只需要把路由相关的状态和回调加进去即可,不用重构整个结构。
内容的提问来源于stack exchange,提问作者JacobHK




