NavigationUtils allows you to customize route transition animations both globally and on a per-page basis using custom Page and Route implementations.
Overview
Custom transitions require:
Creating a custom Page class
Implementing a custom Route that reads the child at build time
Using pageBuilder to apply transitions globally or per-page
Do NOT use PageRouteBuilder for custom transitions. It captures the child widget in a closure at Route creation time, which prevents page updates when using grouped routes or query parameter changes. Always create a custom Route class that reads _page.child at build time.
Why custom Routes matter
Flutter’s Navigator 2 update mechanism requires Routes to read their child widget from the Page settings at build time , not at creation time. This ensures:
Grouped routes properly update when parameters change
Query parameters trigger didUpdateWidget instead of recreating widgets
Page state is preserved during navigation
For detailed technical documentation, see the Flutter Navigator 2 Page Update Mechanism .
Global transitions
Apply a transition to all pages by overriding the pageBuilder in DefaultRouterDelegate.
Create a custom Page class
Define a Page that will hold your child widget: class ScaleTransitionPage extends Page < void > {
final Widget child;
const ScaleTransitionPage ({
required this .child,
super .key,
super .name,
super .arguments,
});
@override
Route < void > createRoute ( BuildContext context) {
return _ScaleTransitionRoute (page : this );
}
}
Implement a custom Route
Create a Route that reads the child from settings at build time: class _ScaleTransitionRoute extends PageRoute < void > {
_ScaleTransitionRoute ({ required ScaleTransitionPage page})
: super (settings : page);
// Read from settings at build time - this is the key!
ScaleTransitionPage get _page => settings as ScaleTransitionPage ;
@override
Color ? get barrierColor => null ;
@override
String ? get barrierLabel => null ;
@override
bool get maintainState => true ;
@override
Duration get transitionDuration => const Duration (milliseconds : 300 );
@override
Widget buildPage ( BuildContext context, Animation < double > animation,
Animation < double > secondaryAnimation) {
return _page.child; // Read child from CURRENT page at build time
}
@override
Widget buildTransitions ( BuildContext context, Animation < double > animation,
Animation < double > secondaryAnimation, Widget child) {
return ScaleTransition (
scale : animation,
alignment : Alignment .center,
child : child,
);
}
}
The critical line is return _page.child; in buildPage(). This reads the child from the current Page settings at build time, not from a captured closure.
Configure NavigationManager
Override the pageBuilder to use your custom Page: NavigationManager . init (
mainRouterDelegate : DefaultRouterDelegate (
navigationDataRoutes : routes,
pageBuilder : ({
key,
name,
child,
routeData,
globalData,
arguments,
}) =>
ScaleTransitionPage (
key : key,
name : name,
arguments : arguments,
child : child,
),
),
routeInformationParser : DefaultRouteInformationParser (),
);
Per-page transitions
Override the global transition for specific routes using the pageBuilder property in NavigationData.
Create a custom Page for the transition
class RightToLeftTransitionPage extends Page < void > {
final Widget child;
const RightToLeftTransitionPage ({
required this .child,
super .key,
super .name,
super .arguments,
});
@override
Route < void > createRoute ( BuildContext context) {
return _RightToLeftRoute (page : this );
}
}
Implement the custom Route
class _RightToLeftRoute extends PageRoute < void > {
_RightToLeftRoute ({ required RightToLeftTransitionPage page})
: super (settings : page);
RightToLeftTransitionPage get _page => settings as RightToLeftTransitionPage ;
@override
Color ? get barrierColor => null ;
@override
String ? get barrierLabel => null ;
@override
bool get maintainState => true ;
@override
Duration get transitionDuration => const Duration (milliseconds : 300 );
@override
Widget buildPage ( BuildContext context, Animation < double > animation,
Animation < double > secondaryAnimation) {
return _page.child; // Read child at build time
}
@override
Widget buildTransitions ( BuildContext context, Animation < double > animation,
Animation < double > secondaryAnimation, Widget child) {
return SlideTransition (
position : Tween < Offset >(
begin : const Offset ( 1.0 , 0.0 ),
end : Offset .zero,
). animate (animation),
child : child,
);
}
}
Use in NavigationData
Apply the custom transition to specific routes: NavigationData (
label : 'Details' ,
url : '/details' ,
builder : (context, routeData, globalData) => DetailsPage (),
pageBuilder : ({key, name, child, routeData, globalData, arguments}) {
return RightToLeftTransitionPage (
key : key,
name : name,
arguments : arguments,
child : child,
);
},
)
Disabling transitions
Global: No transition Page
Create a Page with zero-duration transitions:
class NoTransitionPage extends Page < void > {
final Widget child;
const NoTransitionPage ({
required this .child,
super .key,
super .name,
super .arguments,
});
@override
Route < void > createRoute ( BuildContext context) {
return _NoTransitionRoute (page : this );
}
}
class _NoTransitionRoute extends PageRoute < void > {
_NoTransitionRoute ({ required NoTransitionPage page}) : super (settings : page);
NoTransitionPage get _page => settings as NoTransitionPage ;
@override
bool get opaque => true ;
@override
bool get barrierDismissible => false ;
@override
Color ? get barrierColor => null ;
@override
String ? get barrierLabel => null ;
@override
bool get maintainState => true ;
@override
Duration get transitionDuration => Duration .zero;
@override
Duration get reverseTransitionDuration => Duration .zero;
@override
Widget buildPage ( BuildContext context, Animation < double > animation,
Animation < double > secondaryAnimation) {
return _page.child; // Read from CURRENT page at build time!
}
@override
Widget buildTransitions ( BuildContext context, Animation < double > animation,
Animation < double > secondaryAnimation, Widget child) {
return child; // No transition animation
}
}
Apply globally
NavigationManager . init (
mainRouterDelegate : DefaultRouterDelegate (
navigationDataRoutes : routes,
pageBuilder : ({
key,
name,
child,
routeData,
globalData,
arguments,
}) => NoTransitionPage (
key : key,
name : name,
arguments : arguments,
child : child,
),
),
routeInformationParser : DefaultRouteInformationParser (),
);
Apply per-page
NavigationData (
label : 'No Animation' ,
url : '/no-animation' ,
builder : (context, routeData, globalData) => NoAnimationPage (),
pageBuilder : ({key, name, child, routeData, globalData, arguments}) {
return NoTransitionPage (
key : key,
name : name,
arguments : arguments,
child : child,
);
},
)
Common transition examples
Fade transition
@override
Widget buildTransitions ( BuildContext context, Animation < double > animation,
Animation < double > secondaryAnimation, Widget child) {
return FadeTransition (
opacity : animation,
child : child,
);
}
Slide from bottom
@override
Widget buildTransitions ( BuildContext context, Animation < double > animation,
Animation < double > secondaryAnimation, Widget child) {
return SlideTransition (
position : Tween < Offset >(
begin : const Offset ( 0.0 , 1.0 ),
end : Offset .zero,
). animate ( CurvedAnimation (
parent : animation,
curve : Curves .easeOut,
)),
child : child,
);
}
Rotation transition
@override
Widget buildTransitions ( BuildContext context, Animation < double > animation,
Animation < double > secondaryAnimation, Widget child) {
return RotationTransition (
turns : animation,
child : child,
);
}
Combined scale and fade
@override
Widget buildTransitions ( BuildContext context, Animation < double > animation,
Animation < double > secondaryAnimation, Widget child) {
return ScaleTransition (
scale : CurvedAnimation (
parent : animation,
curve : Curves .easeInOut,
),
child : FadeTransition (
opacity : animation,
child : child,
),
);
}
Advanced: Secondary animations
Use secondaryAnimation to animate the page being covered:
@override
Widget buildTransitions ( BuildContext context, Animation < double > animation,
Animation < double > secondaryAnimation, Widget child) {
return SlideTransition (
position : Tween < Offset >(
begin : const Offset ( 1.0 , 0.0 ),
end : Offset .zero,
). animate (animation),
child : SlideTransition (
position : Tween < Offset >(
begin : Offset .zero,
end : const Offset ( - 0.3 , 0.0 ),
). animate (secondaryAnimation),
child : child,
),
);
}
This creates a parallax effect where the previous page slides left while the new page slides in from the right.
Best practices
Always read child at build time
The Route must read _page.child in buildPage(), not capture it at creation time. This ensures didUpdateWidget is called correctly.
Use appropriate durations
Keep transitions between 200-400ms for optimal user experience: @override
Duration get transitionDuration => const Duration (milliseconds : 300 );
Apply curves for natural motion
Use curves to make animations feel more natural: CurvedAnimation (
parent : animation,
curve : Curves .easeInOut,
)
Ensure your custom transitions work correctly with grouped routes that share widget instances.
Next steps
Nested tabs Combine custom transitions with nested navigation
Cache behavior Understand how NavigationUtils optimizes page caching