Navigation Transitions and Best Practices
Navigation Transitions and Best Practices
A polished Flutter application is not only correct — it feels correct. Custom page transitions, well-organised route declarations, and consistent back-button behaviour are the difference between an app that users describe as "smooth" and one they describe as "janky". In this lesson you will master CustomTransitionPage, learn how to structure routes for large codebases, and apply navigation best practices that prevent common runtime errors.
Understanding CustomTransitionPage
GoRouter wraps every destination in a page object. By default it uses MaterialPage (Android-style slide) or CupertinoPage (iOS-style slide). To replace the animation entirely, supply a CustomTransitionPage as the pageBuilder of a GoRoute.
Fade Transition with CustomTransitionPage
GoRoute(
path: '/details/:id',
pageBuilder: (context, state) {
return CustomTransitionPage<void>(
key: state.pageKey,
child: DetailsPage(id: state.pathParameters['id']!),
transitionDuration: const Duration(milliseconds: 350),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
// Fade in when pushing, fade out when popping
return FadeTransition(
opacity: CurveTween(curve: Curves.easeInOut).animate(animation),
child: child,
);
},
);
},
),
Key constructor parameters:
- key — always pass
state.pageKeyso GoRouter can distinguish pages during transitions. - child — the destination widget (already built; no
buildercallback here). - transitionDuration — controls push duration;
reverseTransitionDurationcontrols pop duration separately. - transitionsBuilder — receives four arguments:
context,animation(0→1 on push),secondaryAnimation(0→1 when a new page pushes on top), andchild.
child in multiple transition widgets. A common combination is FadeTransition around SlideTransition to produce a fade-slide effect without any extra packages.Slide and Scale Transition Examples
Two more patterns you will encounter in production apps:
Slide-Up and Scale Transitions
// Slide up from bottom (modal-style)
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final tween = Tween(
begin: const Offset(0.0, 1.0),
end: Offset.zero,
).chain(CurveTween(curve: Curves.easeOutCubic));
return SlideTransition(position: animation.drive(tween), child: child);
},
// Scale from centre (spotlight effect)
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return ScaleTransition(
scale: Tween<double>(begin: 0.85, end: 1.0)
.chain(CurveTween(curve: Curves.easeOut))
.animate(animation),
child: FadeTransition(
opacity: animation,
child: child,
),
);
},
Organising Routes for Large Apps
Placing every GoRoute in a single file becomes unmanageable once an app grows beyond a dozen screens. The recommended pattern is to split route declarations by feature:
- Create a
router/folder underlib/. - Each feature exposes a
List<RouteBase>constant (e.g.authRoutes,shopRoutes). - A top-level
app_router.dartfile merges them into oneGoRouterinstance. - Use named routes (
name:parameter onGoRoute) and navigate withcontext.goNamed('routeName')so that path changes never break call sites.
AppRoutes abstract class with static const String fields. This removes magic strings from every navigation call and makes renaming a route a one-line refactor.Error Handling in Navigation
GoRouter exposes two hooks for navigation errors:
- errorBuilder — renders a widget when no route matches the requested path (404-style).
- redirect — intercepts every navigation event; throw a
GoExceptionor return a redirect path to guard routes (authentication guards, feature flags).
context.go() or Navigator.push() inside a widget's build() method. Navigation must be triggered by user events (callbacks) or post-frame callbacks (WidgetsBinding.instance.addPostFrameCallback). Navigating during build causes a "setState() called during build" assertion and can crash the app.Android Back-Button Behaviour
GoRouter integrates with the Android back button automatically through the Router widget. However there are cases where you must handle it explicitly:
- Wrap a screen with
PopScope(Flutter 3.16+; replaces the deprecatedWillPopScope) to intercept back presses. - Set
canPop: falseto prevent automatic pops (e.g. payment confirmation screens). - Use the
onPopInvokedWithResultcallback to show a "Do you want to exit?" dialog before allowing the pop. - On web and desktop, remember that the browser/OS back button triggers the same router pop — test on all platforms.
Best Practices Summary
- Always pass
state.pageKeytoCustomTransitionPageto avoid keying bugs. - Use named routes and a central
AppRoutesconstants class. - Split large route trees into per-feature lists and merge them at the app level.
- Provide an
errorBuilderso unmatched paths never show a blank screen. - Use
PopScope(notWillPopScope) for back-button interception. - Keep transition durations between 200–400 ms.
- Never navigate inside
build(); use callbacks oraddPostFrameCallback.
CustomTransitionPage gives you pixel-level control over how screens appear and disappear, while a feature-split route structure and named routes keep a large navigation graph maintainable and refactor-safe.