在 Flutter 中,如果当前页面应用的是 MaterialPageRoute,想要在切换时应用 iOS 风格的切换动画,最简单的方法就是切换时直接使用 CupertinoPageRoute。

https://book.flutterchina.club/chapter9/route_transition.html

但是眼尖的我发现了一个奇怪的现象,当使用 MaterialPageRoute 进入页面 A,然后从页面 A 使用 CupertinoPageRoute 进入页面 B 时,页面 A 的离开动画仍然使用 MaterialPageRoute 的动画,而不是预期的 iOS 风格动画。

我在看很多人的教学文章都没有提到这点,这让我非常困惑。

原因分析:查看 MaterialPageRoute 的原始码会发现,MaterialPageRoute 的 buildTransitions 方法依赖 Theme.of(context).pageTransitionsTheme。

@override
Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
final PageTransitionsTheme theme = Theme.of(context).pageTransitionsTheme;
return theme.buildTransitions<T>(this, context, animation, secondaryAnimation, child);
}

也就是说:

  • 页面的进入和离开动画是成对的,由该页面被推入时使用的 Route 类型决定。
  • 全局 pageTransitionsTheme 设置会影响所有使用 MaterialPageRoute 的页面转换。

theme: ThemeData(
pageTransitionsTheme: PageTransitionsTheme(
builders: Map<TargetPlatform, PageTransitionsBuilder>.fromIterable(
TargetPlatform.values,
value: (dynamic _) => const ZoomPageTransitionsBuilder(),
),
),
),

  • 如果没有特别设定 ThemeData 中的 pageTransitionsTheme,在 Android 会使用 ZoomPageTransitionsBuilder、在 iOS 会使用 CupertinoPageTransitionsBuilder。

那意思不就是说,如果我要随时切换 MaterialPageRoute 的进入与离开动画,自己定义路由动画不就是不可避免的? 😵


解决方案:

  • 使用 Riverpod 的 StateNotifierProvider 来管理转场动画风格。
  • class PageTransitionNotifier extends StateNotifier<PageTransitionsBuilder> {
    PageTransitionNotifier() : super(const FadeUpwardsPageTransitionsBuilder());

    void setCupertinoStyle() {
    state = const CupertinoPageTransitionsBuilder();
    }

    void setZoomStyle() {
    state = const ZoomPageTransitionsBuilder();
    }
    }

    final pageTransitionProvider =
    StateNotifierProvider<PageTransitionNotifier, PageTransitionsBuilder>(
    (ref) {
    return PageTransitionNotifier();
    });

  • 创建自定义的 CustomMaterialPageRoute,覆写 buildTransitions 方法。
  • class CustomMaterialPageRoute<T> extends MaterialPageRoute<T> {
    CustomMaterialPageRoute({
    required super.builder,
    super.settings,
    super.maintainState,
    super.fullscreenDialog,
    });

    @override
    Widget buildTransitions(
    BuildContext context,
    Animation<double> animation,
    Animation<double> secondaryAnimation,
    Widget child,
    ) {
    return Consumer(
    builder: (context, ref, _) {
    final pageTransitionsBuilder = ref.watch(pageTransitionProvider);
    return pageTransitionsBuilder.buildTransitions<T>(
    this,
    context,
    animation,
    secondaryAnimation,
    child,
    );
    },
    );
    }
    }

  • 在需要切换动画风格时,透过 Provider 来控制。
  • ref.read(pageTransitionProvider.notifier).setCupertinoStyle();

    Navigator.push(
    context,
    CustomMaterialPageRoute(
    builder: (context) => const SecondScreen(
    content: \'Cupertino Transition\',
    ),
    ),
    );

    • MaterialPageRoute 的 iOS 切换动画不如直接使用 CupertinoPageRoute 来的流畅,可以把上面的 CustomMaterialPageRoute 改成 CupertinoPageRoute,但要如何控制 CupertinoPageRoute 的离开动画,这又是另一个问题了⋯⋯

    这个解法要注意把初始页面也放在 CustomMaterialPageRoute 中,否则初始页面还是会受到 MaterialApp 中 ThemeData 的影响。

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

    @override
    Widget build(BuildContext context) {
    return MaterialApp(
    title: \'Flutter Demo\',
    theme: ThemeData(
    colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
    useMaterial3: true,
    ),
    initialRoute: \'/\', // 设置初始路由
    onGenerateRoute: (settings) {
    if (settings.name == \'/\') {
    return CustomMaterialPageRoute(
    builder: (context) => const HomeScreen(),
    );
    }
    return null;
    },
    );
    }
    }

    以上解法是自己想出来的,欢迎各路大神讨论。


    然后就在 2024 年 12 月 Flutter 发布了 3.27 更新, 其中就有提到 Mixing Route Transitions,看到当下我整个傻眼,后来就知道这是一个存在已久的已知问题了,但我想说这个框架都推出这么久了直到现在才修复是正常的吗?😂

    难怪有人要推出 Flutter 分叉专案 Flock。

    回归正题,从 GitHub PR #150031 的讨论和程式码对比,可以看出这次改动的主要差异:

  • 主要目的是允许在同一个应用中混合使用不同的路由转场动画。例如可以同时使用 Material 的缩放转场和 Cupertino 的滑动转场。
  • 核心改动是 MaterialPageRoute 和 CupertinoPageRoute 都引入了 DelegatedTransition 机制:
    • 当一个新路由被推入时,它可以告诉下面的路由如何执行退出动画。
    • 下层路由会使用上层路由提供的 delegatedTransition 来替代自己默认的次要转场动画。
  • 具体实现(以 MaterialPageRoute 为例):
    • 在 3.27 版本中,主要新增了 delegatedTransition 相关的功能:

    mixin MaterialRouteTransitionMixin<T> on PageRoute<T> {
    // 3.27 新增
    @override
    DelegatedTransitionBuilder? get delegatedTransition => _delegatedTransition;

    static Widget? _delegatedTransition(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, bool allowSnapshotting, Widget? child) {
    final PageTransitionsTheme theme = Theme.of(context).pageTransitionsTheme;
    final TargetPlatform platform = Theme.of(context).platform;
    final DelegatedTransitionBuilder? themeDelegatedTransition = theme.delegatedTransition(platform);
    return themeDelegatedTransition != null ? themeDelegatedTransition(context, animation, secondaryAnimation, allowSnapshotting, child) : null;
    }
    }

    • canTransitionTo 方法的逻辑也有所改变:

    // 3.24 版本
    @override
    bool canTransitionTo(TransitionRoute<dynamic> nextRoute) {
    return (nextRoute is MaterialRouteTransitionMixin && !nextRoute.fullscreenDialog)
    || (nextRoute is CupertinoRouteTransitionMixin && !nextRoute.fullscreenDialog);
    }

    // 3.27 版本
    @override
    bool canTransitionTo(TransitionRoute<dynamic> nextRoute) {
    final bool nextRouteIsNotFullscreen = (nextRoute is! PageRoute<T>) || !nextRoute.fullscreenDialog;
    final bool nextRouteHasDelegatedTransition = nextRoute is ModalRoute<T>
    && nextRoute.delegatedTransition != null;
    return nextRouteIsNotFullscreen &&
    ((nextRoute is MaterialRouteTransitionMixin) || nextRouteHasDelegatedTransition);
    }

    具体内部做了什么就不深究了,总之把 Flutter 更新到 3.27 来解决这个问题是最佳解。