Navigation & Routing

Navigation Transitions and Best Practices

16 min Lesson 14 of 14

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.pageKey so GoRouter can distinguish pages during transitions.
  • child — the destination widget (already built; no builder callback here).
  • transitionDuration — controls push duration; reverseTransitionDuration controls pop duration separately.
  • transitionsBuilder — receives four arguments: context, animation (0→1 on push), secondaryAnimation (0→1 when a new page pushes on top), and child.
Tip: Compose animations by wrapping 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,
    ),
  );
},
Note: Keep transition durations between 200 ms and 400 ms. Faster than 200 ms feels abrupt; slower than 400 ms feels sluggish on low-end devices. The Material Design guidelines recommend 300 ms for most screen transitions.

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 under lib/.
  • Each feature exposes a List<RouteBase> constant (e.g. authRoutes, shopRoutes).
  • A top-level app_router.dart file merges them into one GoRouter instance.
  • Use named routes (name: parameter on GoRoute) and navigate with context.goNamed('routeName') so that path changes never break call sites.
Tip: Define route name constants in a single 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 GoException or return a redirect path to guard routes (authentication guards, feature flags).
Warning: Never call 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 deprecated WillPopScope) to intercept back presses.
  • Set canPop: false to prevent automatic pops (e.g. payment confirmation screens).
  • Use the onPopInvokedWithResult callback 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.pageKey to CustomTransitionPage to avoid keying bugs.
  • Use named routes and a central AppRoutes constants class.
  • Split large route trees into per-feature lists and merge them at the app level.
  • Provide an errorBuilder so unmatched paths never show a blank screen.
  • Use PopScope (not WillPopScope) for back-button interception.
  • Keep transition durations between 200–400 ms.
  • Never navigate inside build(); use callbacks or addPostFrameCallback.
Key Takeaway: Thoughtful transitions and organised route code work together. 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.