TpRouter

中文文档

Package Version
tp_router pub package
tp_router_annotation pub package
tp_router_generator pub package

A simplified, type-safe, and annotation-driven routing library for Flutter.

TpRouter is built on top of go_router—the official Flutter routing package. This means you get all the battle-tested features (deep linking, web support, nested navigation) without worrying about core stability. TpRouter simply provides a more ergonomic, annotation-based API that eliminates boilerplate and enables type-safe navigation.


Table of Contents


✨ Features

  • 🗝️ NavKey-Driven Linking: No more nesting hell. Just tell a route "My parent is MainNavKey", and they are automatically linked.
  • 📐 Type-Safe Navigation: UserRoute(id: 1).tp() instead of string manipulation.
  • 🐚 Simple Shells: Define app layouts (BottomNav, Drawers) purely through annotations.
  • 🛡️ Type-Safe Guards: Strongly-typed TpRedirect<T> for route protection.
  • 🔄 Reactive Routing: Use refreshListenable to auto-redirect on state changes.
  • 🧩 Smart Code Gen: Automatically handles parameters, return values, and deep linking.

🧩 Core Concepts

Understanding how TpRouter works helps you leverage its full power.

1. The Triad of Navigation

TpRouter connects three key pieces:

  • Routes (@TpRoute): Static configuration of what screens you have.
  • Generator: Converts annotations into strongly-typed classes (UserRoute, HomeRoute).
  • Router (TpRouter): The runtime engine that manages the navigation stack using go_router.

2. TpNavKey: The Bridge

TpNavKey is more than just a GlobalKey. It is the binding agent that connects:

  • A Shell (UI container)
  • A Navigator (Flutter's navigation stack)
  • An Observer (TpRouter's tracking system)

When you define class MainKey extends TpNavKey, you are creating a unique identifier that ensures your ShellRoute uses the exact same navigator instance that your routes are trying to navigate into.

3. Smart Observation

TpRouter automatically injects TpRouteObserver into every navigator managed by a TpNavKey (especially in ShellRoutes). This observer tracks the live route stack, enabling advanced features like:

  • popUntil(predicate)
  • popToInitial()
  • removeWhere()

Normal go_router doesn't easily support these because it manages URLs, not Flutter Route objects. TpRouter bridges this gap by watching the actual Navigator activities.

4. Architecture Deep Dive

TpRouter is designed as a compile-time abstraction layer over go_router.

Layer Component Role
User Code Annotations (@TpRoute) Define the navigation structure and parameters declaratively.
Build System tp_router_generator Analyzes code and generates type-safe Route classes.
Runtime TpRouteData The common interface for all routes. It unifies parameters (path, query, extra) into a single API.
Core TpRouter A singleton wrapper adjusting go_router configuration and managing global state.
Engine go_router Handles URL parsing, deep linking, and low-level navigation.

Data Flow:

  1. Code Gen: Annotated UserPage(id) becomes UserRoute(id).
  2. Navigation: Calling UserRoute(id: 123).tp() converts the object into a URL path (/user/123) and extra data.
  3. Routing: TpRouter tells go_router to navigate.
  4. Reconstruction: When the page builds, TpRouter uses TpRouteData.of(context) to parse the URL/Web State back into usable data.

Installation

Add the following to your pubspec.yaml:

dependencies:
  tp_router: ^0.5.1
  tp_router_annotation: ^0.5.0

dev_dependencies:
  build_runner: ^2.4.0
  tp_router_generator: ^0.5.0

Run the generator:

dart run build_runner build

🚀 Quick Start

1. Define NavKeys

NavKeys are the heart of TpRouter. They act as unique identifiers for your navigators and bridges between parents and children.

Create a file lib/routes/nav_keys.dart:

import 'package:tp_router/tp_router.dart';

// Key for the main application shell (e.g. BottomNavigationBar)
class MainNavKey extends TpNavKey {
  const MainNavKey() : super('main');
}

// Sub-keys for branches if you use IndexedStack (optional but recommended)
class HomeNavKey extends TpNavKey {
  const HomeNavKey() : super('main', branch: 0);
}

class SettingsNavKey extends TpNavKey {
  const SettingsNavKey() : super('main', branch: 1);
}

2. Define Shells

Mark your container widget (e.g., a page with BottomNavigationBar) with @TpShellRoute. Link it to a key (MainNavKey).

@TpShellRoute(
  navigatorKey: MainNavKey, // <--- Identified by this Key
  isIndexedStack: true,     // Enable stateful nested navigation
  branchKeys: [HomeNavKey, SettingsNavKey], // <--- Define branch key types (not instances)
)
class MainShellPage extends StatelessWidget {
  final TpStatefulNavigationShell navigationShell;
  const MainShellPage({required this.navigationShell, super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: navigationShell,
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: navigationShell.currentIndex,
        onTap: (index) => navigationShell.tp(index),
        items: [/* ... */],
      ),
    );
  }
}

3. Define Routes

Just annotate your pages.

  • To nest a page, simply set parentNavigatorKey to the Shell's key.
  • No nesting? Omit the key.
// Standard Route (not nested)
@TpRoute(path: '/login')
class LoginPage extends StatelessWidget { ... }

// Nested Route (Child of MainShellPage)
@TpRoute(
  path: '/home',
  isInitial: true,
  parentNavigatorKey: HomeNavKey, // <--- Linked to MainShell's branch 0 automatically!
)
class HomePage extends StatelessWidget { ... }

// Another Nested Route
@TpRoute(
  path: '/settings',
  parentNavigatorKey: SettingsNavKey, // <--- Linked to MainShell's branch 1
)
class SettingsPage extends StatelessWidget { ... }

4. Initialize Router

Pass the generated tpRoutes to TpRouter.

import 'routes/route.gr.dart';

void main() {
  final router = TpRouter(
    routes: tpRoutes, // Generated by build_runner
  );

  runApp(MaterialApp.router(
    routerConfig: router.routerConfig,
  ));
}

🧭 Navigation

Type-Safe Navigation

The generator creates a Route class for every annotated widget.

// Push a new route
UserRoute(id: 123).tp();

// Await a result
final result = await SelectProfileRoute().tp<String>();

// Replace current route (no back navigation)
LoginRoute().tp(replacement: true);

// Clear history and go (like go_router's `go`)
HomeRoute().tp(clearHistory: true);

Pop & Stack Control

// Pop topmost route
context.pop();
// or with result
context.pop(result: 'selected_item');

// --- Using context extensions (Recommended) ---

// Access TpRouter helper via context
context.tpRouter.popTo(HomeRoute());

context.tpRouter.popToInitial();

// --- Using Static Instance (Global) ---

// Pop until a specific route
TpRouter.instance.popTo(HomeRoute());

// Pop to initial route
TpRouter.instance.popToInitial();

// Remove a specific route from stack (without navigating)
TpRouter.instance.removeRoute(SomeRoute());

// Remove routes matching condition
TpRouter.instance.removeWhere((data) => data.fullPath.contains('/temp'));

📦 Parameters

TpRouter provides powerful, type-safe parameter parsing.

Path Parameters

Use @Path() to extract values from the URL path.

@TpRoute(path: '/user/:id')
class UserPage extends StatelessWidget {
  const UserPage({required this.userId});
  
  @Path('id')
  final String userId;
}

// Navigation
UserRoute(userId: '123').tp(); // -> /user/123

Query Parameters

Use @Query() to extract values from the query string.

@TpRoute(path: '/search')
class SearchPage extends StatelessWidget {
  const SearchPage({this.query, this.page});
  
  @Query('q')
  final String? query;
  
  @Query('page')
  final int? page; // Auto-parsed to int
}

// Navigation
SearchRoute(query: 'flutter', page: 2).tp(); // -> /search?q=flutter&page=2

Extra (Complex Objects)

For non-serializable objects (like models), they are passed via memory.

@TpRoute(path: '/profile')
class ProfilePage extends StatelessWidget {
  const ProfilePage({required this.user});
  final User user; // Complex object, not in URL
}

// Navigation
ProfileRoute(user: currentUser).tp();

⚠️ Note: Extra objects are NOT preserved during:

  • Browser Refresh
  • Direct URL entry (e.g. typing URL in address bar)
  • App kill/restart

For persistent data, use path/query params or state management services.

Combined Example

@TpRoute(path: '/order/:orderId/detail')
class OrderDetailPage extends StatelessWidget {
  const OrderDetailPage({
    required this.orderId,
    this.highlightItem,
    required this.orderData,
  });
  
  @Path('orderId')
  final String orderId;
  
  @Query('highlight')
  final String? highlightItem;
  
  final Order orderData; // Extra (passed via memory)
}

// Navigation
OrderDetailRoute(
  orderId: 'ORD-123',
  highlightItem: 'item-5',
  orderData: order,
).tp();
// URL: /order/ORD-123/detail?highlight=item-5
// orderData passed via memory

🛡️ Guards & Redirects

Route-Level Redirect

Protect specific routes. The redirect parameter accepts a TpRedirect<T> class.

// 1. Define the guard
class AuthGuard extends TpRedirect<ProtectedRoute> {
  @override
  FutureOr<TpRouteData?> handle(BuildContext context, ProtectedRoute route) {
    // Access the typed route object!
    if (!AuthService.instance.isLoggedIn) {
      return const LoginRoute(); // Redirect to login
    }
    return null; // Proceed (allow access)
  }
}

// 2. Apply to route
@TpRoute(path: '/protected', redirect: AuthGuard)
class ProtectedPage extends StatelessWidget { ... }

Global Redirect

For app-wide rules (e.g., onboarding check, maintenance mode).

final router = TpRouter(
  routes: tpRoutes,
  redirect: (context, state) {
    // state.fullPath is the target URL
    if (needsOnboarding && state.fullPath != '/onboarding') {
      return OnboardingRoute();
    }
    if (isLoggedIn && state.fullPath == '/login') {
      return HomeRoute(); // Already logged in, skip login
    }
    return null; // Allow
  },
);

Reactive Routing (refreshListenable)

This is how you make guards respond to state changes (e.g., login/logout).

Without refreshListenable, the router doesn't know when to re-evaluate guards. After login, you'd be stuck on the login page even if the guard logic allows access.

// 1. Create a listenable auth service
class AuthService extends ChangeNotifier {
  static final instance = AuthService();
  
  bool _isLoggedIn = false;
  bool get isLoggedIn => _isLoggedIn;

  void login() {
    _isLoggedIn = true;
    notifyListeners(); // 🔔 Signal the router!
  }

  void logout() {
    _isLoggedIn = false;
    notifyListeners(); // 🔔 Signal the router!
  }
}

// 2. Pass to TpRouter
final router = TpRouter(
  routes: tpRoutes,
  refreshListenable: AuthService.instance, // <-- KEY!
  redirect: (context, state) {
    final loggedIn = AuthService.instance.isLoggedIn;
    final isOnLogin = state.fullPath == '/login';
    
    if (!loggedIn && !isOnLogin) return LoginRoute();
    if (loggedIn && isOnLogin) return HomeRoute();
    return null;
  },
);

// 3. Now when you call login()...
AuthService.instance.login();
// ...the router automatically re-evaluates and redirects!

How it works:

  1. User navigates to /protected.
  2. Guard runs, isLoggedIn is false → redirect to /login.
  3. User logs in → AuthService.login() calls notifyListeners().
  4. TpRouter (listening to refreshListenable) re-runs the redirect logic.
  5. Now isLoggedIn is true → user is allowed through (or redirected to home if on login page).

🔄 Route Lifecycle

OnExit Guard

Intercept back navigation (e.g., unsaved changes confirmation).

// 1. Define the exit guard
class UnsavedChangesGuard extends TpOnExit<EditorRoute> {
  @override
  FutureOr<bool> onExit(BuildContext context, EditorRoute route) async {
    final shouldExit = await showDialog<bool>(
      context: context,
      builder: (c) => AlertDialog(
        title: Text('Unsaved Changes'),
        content: Text('Discard changes?'),
        actions: [
          TextButton(onPressed: () => Navigator.pop(c, false), child: Text('Cancel')),
          TextButton(onPressed: () => Navigator.pop(c, true), child: Text('Discard')),
        ],
      ),
    );
    return shouldExit ?? false; // true = allow exit, false = block
  }
}

// 2. Apply to route
@TpRoute(path: '/edit', onExit: UnsavedChangesGuard)
class EditorPage extends StatelessWidget { ... }

🔗 Deep Linking

TpRouter fully supports deep linking out of the box. All path and query parameters are automatically parsed.

How it works:

  1. Define your route with path parameters: @TpRoute(path: '/product/:id').
  2. When a deep link like yourapp://product/123?ref=email is opened:
    • id is extracted as '123'.
    • ref is extracted as 'email'.
  3. Your page receives fully typed parameters.

Example:

@TpRoute(path: '/product/:productId')
class ProductPage extends StatelessWidget {
  const ProductPage({required this.productId, this.referrer});
  
  @Path('productId')
  final String productId;
  
  @Query('ref')
  final String? referrer;
}

Deep link URL: https://example.com/product/abc123?ref=instagram

Platform Setup:

  • iOS: Configure Associated Domains in Xcode.
  • Android: Add intent-filter to AndroidManifest.xml.
  • Web: Works automatically.

See go_router deep linking guide for platform-specific setup (TpRouter uses go_router internally).


🎨 Page Transitions

Built-in Transitions

@TpRoute(
  path: '/details',
  transition: TpSlideTransition(), // TpSlideTransition, TpFadeTransition, etc.
  transitionDuration: 300,        // milliseconds
  reverseTransitionDuration: 200, // milliseconds (optional)
)
class DetailsPage extends StatelessWidget { ... }

Available transitions:

Transition Description
TpSlideTransition Slide from right
TpFadeTransition Fade in/out
TpScaleTransition Scale up/down
TpNoTransition No animation
TpCupertinoPageTransition iOS-style slide

Custom Transitions

Implement TpTransitionsBuilder:

class MyCustomTransition extends TpTransitionsBuilder {
  const MyCustomTransition();
  
  @override
  Widget buildTransitions(
    BuildContext context,
    Animation<double> animation,
    Animation<double> secondaryAnimation,
    Widget child,
  ) {
    return RotationTransition(
      turns: animation,
      child: child,
    );
  }
}

// Apply globally
final router = TpRouter(
  routes: tpRoutes,
  defaultTransition: MyCustomTransition(),
);

// Or per-route (via annotation - requires custom setup)

Swipe Back

Enable full-screen swipe-to-go-back gesture.

// Global default
final router = TpRouter(
  routes: tpRoutes,
  defaultPageType: TpPageType.swipeBack,
);

// Or use Cupertino-style (edge swipe only)
defaultPageType: TpPageType.cupertino,

📄 Page Configuration

The @TpRoute annotation supports rich page configuration options for dialogs, modals, transparency, and more.

Page Type (TpPageType)

Control how the page is rendered:

@TpRoute(
  path: '/settings',
  type: TpPageType.cupertino, // Force iOS-style page
)
class SettingsPage extends StatelessWidget { ... }
Type Description
TpPageType.auto Platform-adaptive (default). Material on Android, Cupertino on iOS.
TpPageType.material Force MaterialPage (Android-style).
TpPageType.cupertino Force CupertinoPage (iOS-style with edge swipe).
TpPageType.swipeBack Full-screen swipe-to-dismiss gesture.
TpPageType.custom Use with pageBuilder for fully custom pages.

Note on TpPageType.auto:

  • Android: Uses ZoomPageTransitionsBuilder (Android 10+) or standard slide up/fade.
  • iOS: Uses CupertinoPageTransition (slide from right with swipe-back).
  • macOS/Linux/Windows: Uses ZoomPageTransitionsBuilder or standard fade.

This ensures your app feels native on every platform without manual configuration.

Dialog & Modal Options

Create fullscreen dialogs (iOS modal sheets):

@TpRoute(
  path: '/create-post',
  fullscreenDialog: true, // Shows close button instead of back arrow on iOS
)
class CreatePostPage extends StatelessWidget { ... }

Transparent Pages

Create transparent overlays, bottom sheets, or custom modals:

@TpRoute(
  path: '/overlay',
  opaque: false,                          // Page is transparent
  barrierColor: Color(0x80000000),         // Semi-transparent black barrier
  barrierDismissible: true,                // Tap barrier to close
  barrierLabel: 'Dismiss overlay',         // Accessibility label
)
class OverlayPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Align(
      alignment: Alignment.bottomCenter,
      child: Container(
        height: 300,
        decoration: BoxDecoration(
          color: Colors.white,
          borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
        ),
        child: Text('Bottom Sheet Content'),
      ),
    );
  }
}

Full @TpRoute Reference

All available @TpRoute parameters:

@TpRoute(
  // === Core ===
  path: '/user/:id',              // URL path pattern
  isInitial: false,               // Mark as initial route
  parentNavigatorKey: SomeNavKey, // Nest under a shell
  
  // === Guards ===
  redirect: AuthGuard,            // Redirect logic (TpRedirect<T>)
  onExit: UnsavedChangesGuard,    // Exit interception (TpOnExit<T>)
  
  // === Page Type ===
  type: TpPageType.auto,          // auto, material, cupertino, swipeBack, custom
  pageBuilder: MyCustomPage,      // Custom Page factory (overrides type)
  
  // === Transitions ===
  transition: TpSlideTransition(),        // Custom transition builder
  transitionDuration: Duration(milliseconds: 300),
  reverseTransitionDuration: Duration(milliseconds: 300),
  
  // === Dialog/Modal ===
  fullscreenDialog: false,        // iOS modal style (close button)
  opaque: true,                   // false = transparent page
  barrierDismissible: false,      // Tap outside to dismiss
  barrierColor: null,             // Barrier color (e.g. Color(0x80000000))
  barrierLabel: null,             // Accessibility label for barrier
  
  // === State ===
  maintainState: true,            // Keep state when inactive
)
class MyPage extends StatelessWidget { ... }

Full @TpShellRoute Reference

@TpShellRoute supports page configuration plus shell-specific options:

@TpShellRoute(
  // === Core ===
  navigatorKey: MainNavKey,           // Required: Shell identifier
  parentNavigatorKey: RootNavKey,     // Optional: Nest shells
  isIndexedStack: true,               // Use StatefulShellRoute (preserves tab state)
  branchKeys: [HomeNavKey, ProfileNavKey], // Branch identifiers for IndexedStack
  
  // === Observers ===
  observers: [MyNavigatorObserver, AnalyticsObserver], // NavigatorObservers for this shell
  
  // === Page Configuration (same as TpRoute) ===
  type: TpPageType.material,
  fullscreenDialog: false,
  opaque: true,
  barrierDismissible: false,
  barrierColor: null,
  barrierLabel: null,
  maintainState: true,
  pageBuilder: MyShellPageBuilder,    // Custom Page factory
)
class MainShell extends StatelessWidget { ... }

Observer Example:

class AnalyticsObserver extends NavigatorObserver {
  @override
  void didPush(Route route, Route? previousRoute) {
    analytics.logPageView(route.settings.name);
  }
}

@TpShellRoute(
  navigatorKey: MainNavKey,
  observers: [AnalyticsObserver], // Attach to shell's navigator
)
class MainShell extends StatelessWidget { ... }

⚙️ Configuration

TpRouter Options

TpRouter(
  routes: tpRoutes,
  
  // Initial location (auto-detected from isInitial if not set)
  initialLocation: '/home',
  
  // Global redirect
  redirect: (context, state) => null,
  
  // Reactive routing trigger
  refreshListenable: authNotifier,
  
  // Error page
  errorBuilder: (context, state) => ErrorPage(error: state.error),
  
  // Debug logging
  debugLogDiagnostics: true,
  
  // Transition defaults
  defaultTransition: TpSlideTransition(),
  defaultTransitionDuration: Duration(milliseconds: 300),
  defaultReverseTransitionDuration: Duration(milliseconds: 200),
  
  // Page type: auto, material, cupertino, swipeBack
  defaultPageType: TpPageType.auto,
  
  // Custom navigator key (must be a specific key instance)
  navigatorKey: const RootNavKey(),
  
  // Restoration for state persistence
  restorationScopeId: 'app_router',
  
  // Redirect limit to prevent infinite loops
  redirectLimit: 5,
);

build.yaml Options

Customize generator output:

targets:
  $default:
    builders:
      tp_router_generator:
        options:
          output: lib/routes/app_routes.dart # Custom output path

📝 License

MIT License. See LICENSE for details.

Libraries

tp_router
Export all public APIs from tp_router.