Start Debugging
2026-10-08 Updated 2026-10-08 migrationflutterdartnavigation Edit on GitHub

Migrate custom page transitions after the Flutter page transition builders reorganization (Flutter 3.44 to 3.47)

Flutter moved PageTransitionsBuilder and two built-in builders into the widgets layer and CupertinoPageTransitionsBuilder out of Material. What actually breaks (one import, with a misleading 'Not a constant expression' error), what dart fix does and gets wrong for material_ui projects, and how to rewrite custom builders and routes so they no longer depend on Material at all.

For most apps this is a five-minute migration with exactly one source-breaking change: since Flutter 3.44, CupertinoPageTransitionsBuilder lives in the Cupertino library, so any file that imports only package:flutter/material.dart (or package:material_ui/material_ui.dart) and puts that builder into a PageTransitionsTheme stops compiling. Add the Cupertino import and you are done. The rest of the reorganization, which moved the PageTransitionsBuilder base class plus FadeUpwardsPageTransitionsBuilder and OpenUpwardsPageTransitionsBuilder into package:flutter/widgets.dart in Flutter 3.38 and 3.41, is non-breaking, but it is the part worth acting on: your custom builders can now drop their Material dependency entirely, which is what keeps them working when you move to the standalone design packages. Everything below was compiled and tested on Flutter 3.44.8 with Dart 3.12.2, and checked against the current stable, Flutter 3.47.6, with material_ui 1.6.0 and cupertino_ui 1.1.2.

Why the builders were moved

PageTransitionsBuilder started life as a Material class because PageTransitionsTheme and MaterialPageRoute were its only consumers. That made no sense for a Cupertino app, or for a team with its own design system built on WidgetsApp: to reuse a transition object, you had to import Material. Flutter’s issue #172929 (“Move platform specific page transitions outside of Material and Cupertino”) tracked untangling that, as part of the larger effort to ship Material and Cupertino as separate packages.

The concrete outcomes:

What breaks

AreaChangeLanded in stableSeverity
PageTransitionsBuilderMoved from Material to widgets.dart (PR #174321)3.38none, Material re-exports widgets
FadeUpwardsPageTransitionsBuilderMoved to widgets.dart (PR #175560)3.41none
OpenUpwardsPageTransitionsBuilderMoved to widgets.dart (PR #177080)3.41none
CupertinoPageTransitionsBuilderMoved from Material to cupertino.dart (PR #179776)3.44high for Material-only files, one import fixes it
ZoomPageTransitionsBuilder, FadeForwardsPageTransitionsBuilder, PredictiveBackPageTransitionsBuilder, PageTransitionsThemeUnchanged, still Materialn/anone

The first three rows are invisible to a Material app because material.dart does export 'package:flutter/widgets.dart'. A file that writes extends PageTransitionsBuilder with only a Material import resolves the class through that re-export, exactly as before. The official breaking change page lists FadeUpwardsPageTransitionsBuilder and OpenUpwardsPageTransitionsBuilder under “Material” for the same reason: from a Material import, that is where they appear to come from.

Pre-flight checklist

Migration steps

  1. Upgrade and reproduce the failure. Move to the target SDK and run the analyzer, which gives a much clearer message than the compiler:

    # Flutter 3.44.8 or later
    flutter upgrade
    flutter analyze

    Take this ThemeData from a typical app, which compiled fine on 3.41:

    // Flutter 3.44.8, Dart 3.12.2 -- fails to compile
    import 'package:flutter/material.dart';
    
    final ThemeData theme = ThemeData(
      pageTransitionsTheme: const PageTransitionsTheme(
        builders: <TargetPlatform, PageTransitionsBuilder>{
          TargetPlatform.android: PredictiveBackPageTransitionsBuilder(),
          TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
          TargetPlatform.macOS: CupertinoPageTransitionsBuilder(),
        },
      ),
    );

    flutter analyze reports the real cause, undefined_method: “The method ‘CupertinoPageTransitionsBuilder’ isn’t defined”, plus invalid_constant and non_constant_map_value noise for each entry. Verify: you see one undefined_method per CupertinoPageTransitionsBuilder usage and no other new errors.

  2. Add the Cupertino import to each affected file. On the SDK libraries:

    // Flutter 3.44+, SDK libraries
    import 'package:flutter/cupertino.dart';
    import 'package:flutter/material.dart';

    On the standalone packages:

    // Flutter 3.47.6, material_ui 1.6.0, cupertino_ui 1.1.2
    import 'package:cupertino_ui/cupertino_ui.dart';
    import 'package:material_ui/material_ui.dart';

    material_ui already depends on cupertino_ui, but importing a transitive dependency trips the depend_on_referenced_packages lint, so add it explicitly with flutter pub add cupertino_ui. Verify: flutter analyze is clean for those files.

  3. Or let dart fix do it, then check the result. Both libraries ship a data-driven fix for this move (the replacedBy entry in fix_material.yaml):

    # Flutter 3.44+
    dart fix --dry-run
    dart fix --apply

    On an SDK-libraries project this inserts import 'package:flutter/cupertino.dart'; and nothing else, which is correct. On a material_ui 1.6.0 project, the fix data still points at package:flutter/cupertino.dart, the frozen SDK copy, not at cupertino_ui. Your code compiles, because the SDK builder extends the same widgets-layer base class, but you have just reintroduced an SDK design library import into a project you migrated away from it. Replace that line with the cupertino_ui import by hand. Verify: grep -rn "package:flutter/cupertino.dart" lib returns nothing on a migrated project.

  4. Retarget custom builders at the widgets layer. A builder that only composes SlideTransition, FadeTransition, ScaleTransition and curves has no reason to import Material any more:

    // Flutter 3.44+, Dart 3.12 -- no Material import needed
    import 'package:flutter/widgets.dart';
    
    class FadeSlidePageTransitionsBuilder extends PageTransitionsBuilder {
      const FadeSlidePageTransitionsBuilder();
    
      @override
      Duration get transitionDuration => const Duration(milliseconds: 250);
    
      @override
      Widget buildTransitions<T>(
        PageRoute<T> route,
        BuildContext context,
        Animation<double> animation,
        Animation<double> secondaryAnimation,
        Widget child,
      ) {
        final Animation<Offset> position = animation.drive(
          Tween<Offset>(begin: const Offset(0.0, 0.08), end: Offset.zero)
              .chain(CurveTween(curve: Curves.easeOutCubic)),
        );
        return FadeTransition(
          opacity: animation,
          child: SlideTransition(position: position, child: child),
        );
      }
    }

    The same class still drops into a Material theme unchanged, because PageTransitionsTheme.builders is typed against this exact base class. Verify: the file’s only Flutter import is widgets.dart and flutter analyze is clean.

  5. Replace PageRouteBuilder boilerplate with a route that delegates to a builder. This is the pattern the reorganization was designed for: one route class, any transition, no Material:

    // Flutter 3.44+, Dart 3.12
    import 'package:flutter/widgets.dart';
    
    class BuilderPageRoute<T> extends PageRoute<T> {
      BuilderPageRoute({
        required this.builder,
        this.transitionsBuilder = const FadeSlidePageTransitionsBuilder(),
        super.settings,
      });
    
      final WidgetBuilder builder;
      final PageTransitionsBuilder transitionsBuilder;
    
      @override
      Duration get transitionDuration => transitionsBuilder.transitionDuration;
    
      @override
      Duration get reverseTransitionDuration =>
          transitionsBuilder.reverseTransitionDuration;
    
      @override
      DelegatedTransitionBuilder? get delegatedTransition =>
          transitionsBuilder.delegatedTransition;
    
      @override
      Color? get barrierColor => null;
    
      @override
      String? get barrierLabel => null;
    
      @override
      bool get maintainState => true;
    
      @override
      Widget buildPage(
        BuildContext context,
        Animation<double> animation,
        Animation<double> secondaryAnimation,
      ) => builder(context);
    
      @override
      Widget buildTransitions(
        BuildContext context,
        Animation<double> animation,
        Animation<double> secondaryAnimation,
        Widget child,
      ) => transitionsBuilder.buildTransitions<T>(
        this,
        context,
        animation,
        secondaryAnimation,
        child,
      );
    }

    Forwarding transitionDuration, reverseTransitionDuration and delegatedTransition matters. The official sample hardcodes 300 ms, which silently ignores the duration the builder declares, and without delegatedTransition a CupertinoPageTransitionsBuilder passed to this route animates the incoming page but leaves the previous page frozen instead of sliding it left. Verify with a widget test (next section).

  6. Plug the route into whatever app widget you use. For a WidgetsApp-based design system, pass it as pageRouteBuilder:

    // Flutter 3.44+
    WidgetsApp(
      color: const Color(0xFF0B57D0),
      pageRouteBuilder: <T>(RouteSettings settings, WidgetBuilder builder) =>
          BuilderPageRoute<T>(builder: builder, settings: settings),
      home: const HomeScreen(),
    );

    In a Material app, keep using PageTransitionsTheme for the default and push BuilderPageRoute only where a screen needs a different transition. Verify: navigating to a pushed screen shows the new animation, and flutter analyze is clean.

Verification

Do not trust your eyes on a 250 ms animation. Pump the route halfway and assert on the transition widget:

// Flutter 3.44.8, flutter_test
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/transitions.dart';

void main() {
  testWidgets('BuilderPageRoute uses the builder duration', (tester) async {
    final navigator = GlobalKey<NavigatorState>();
    await tester.pumpWidget(WidgetsApp(
      navigatorKey: navigator,
      color: const Color(0xFF000000),
      pageRouteBuilder: <T>(RouteSettings s, WidgetBuilder b) =>
          BuilderPageRoute<T>(builder: b, settings: s),
      home: const Text('home', textDirection: TextDirection.ltr),
    ));

    navigator.currentState!.push(BuilderPageRoute<void>(
      builder: (_) => const Text('second', textDirection: TextDirection.ltr),
    ));
    await tester.pump();
    await tester.pump(const Duration(milliseconds: 125));

    final fade = tester.widget<FadeTransition>(find
        .ancestor(of: find.text('second'), matching: find.byType(FadeTransition))
        .first);
    expect(fade.opacity.value, 0.5);

    await tester.pumpAndSettle();
    expect(find.text('second'), findsOneWidget);
  });
}

On Flutter 3.44.8 this passes with opacity exactly 0.5 at 125 ms, which proves the route picked up the builder’s 250 ms duration. If someone hardcodes 300 ms again, the value drops to about 0.42 and the test fails. Beyond that:

Rollback plan

The code changes are additive: an extra import and a few classes that no longer need Material. All of them compile on 3.41 too, except that on 3.41 CupertinoPageTransitionsBuilder resolves through the Material import, so the added cupertino.dart import is merely redundant. Rolling back the SDK with flutter downgrade or a pinned version in CI does not require reverting any of this. The one thing that cannot go back below 3.38 is a builder that imports only widgets.dart, since the base class was not there yet.

Gotchas

The compiler error points at the wrong problem. Inside a const map, which is how almost every PageTransitionsTheme is written, the front end does not say the name is undefined. flutter build and flutter test print only:

lib/main.dart:14:33: Error: Not a constant expression.
            TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
                                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

People delete const, which turns it into “The method ‘CupertinoPageTransitionsBuilder’ isn’t defined for the type ‘App’”, and then go looking for a method. Run flutter analyze first; it shows the undefined_method diagnostic alongside the constant noise.

A partial builders map does not merge with the defaults. Passing builders: replaces the whole default map, and missing platforms fall back at runtime to CupertinoPageTransitionsBuilder on iOS only and ZoomPageTransitionsBuilder everywhere else, macOS included. If you only list Android and iOS, macOS gets the zoom transition. While you are in this file anyway, list every platform you ship.

Mixing the SDK and package Cupertino imports in one file. On a material_ui project, a file that imports both package:flutter/cupertino.dart (left behind by dart fix) and package:cupertino_ui/cupertino_ui.dart gets ambiguous_import errors for every Cupertino name. Keep exactly one.

Stale hide clauses. Some codebases wrote import 'package:flutter/material.dart' hide CupertinoPageTransitionsBuilder; to avoid a clash with a local class of the same name. On 3.44+ that name no longer exists in the Material namespace, and the analyzer flags undefined_hidden_name. Delete the clause.

Third-party builders keep working. SharedAxisPageTransitionsBuilder from animations and similar classes extend the base class through their own Material import, which re-exports the widgets-layer type, so they still slot into your theme. Only packages that themselves reference CupertinoPageTransitionsBuilder with a Material-only import break, and those need a package release, not a change in your app.

Subclassing a Material builder still needs Material. ZoomPageTransitionsBuilder, FadeForwardsPageTransitionsBuilder and the predictive back builders stayed in Material. If your custom builder extends one of them to tweak a duration, it keeps its Material (or material_ui) import.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back