navhost 0.1.5 copy "navhost: ^0.1.5" to clipboard
navhost: ^0.1.5 copied to clipboard

A Compose-inspired declarative navigation wrapper for Flutter's Navigator 2.0 with NavController, NavHost, transitions, interceptors, and bottom sheet/dialog support.

navhost #

A Compose-inspired declarative navigation wrapper for Flutter's Navigator 2.0.

navhost brings Jetpack Compose's NavController / NavHost mental model to Flutter — declarative back stack management, path-based routing with parameters, Compose-style 4-way transitions, interceptors, and modal support — all built on top of Flutter's standard Navigator 2.0 APIs.

Table of contents #

Features #

  • Declarative navigationnavigate(), pop(), popUntil(), switchTo() manage a back stack that drives Navigator.pages
  • Path parameters/user/:uid/post/:pid extracts {uid: "42", pid: "7"}
  • Compose-style transitionsenterTransition, exitTransition, popEnterTransition, popExitTransition per route or as NavHost defaults
  • Navigation interceptors — redirect or block navigation before the stack changes (auth guards, onboarding flows)
  • launchSingleTop — avoid duplicate entries at the top of the stack
  • popUpTo / popUpToInclusive — pop the stack to a target before pushing
  • Bottom sheets & dialogs — declarative (stack-managed) and imperative (returns a result)
  • Inline widget navigation — push arbitrary widgets without defining a route
  • Back stack observationcurrentEntry, previousEntry, backStack with path and params
  • Nested NavHosts — sub-routing with independent back stacks (tab navigation)
  • MaterialApp.router integration — provides routerDelegate and routeInformationParser

Getting started #

dependencies:
  navhost: ^0.1.0
import 'package:navhost/navhost.dart';

Usage #

Basic setup #

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  final _navController = NavController(
    routes: [
      NavRoute('/', (_, _) => const HomePage()),
      NavRoute('/item/:id', (params, _) => DetailPage(id: params['id']!)),
    ],
  );

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routerDelegate: _navController.delegate,
      routeInformationParser: _navController.parser,
    );
  }
}
final nav = context.navController;

// Push onto the stack
nav.navigate('/item/42');

// Replace the entire stack
nav.switchTo('/home');

// Avoid duplicate top entry
nav.navigate('/item/1', launchSingleTop: true);

// Pop to a target, then push
nav.navigate('/settings', popUpTo: '/', popUpToInclusive: false);

// Pop back
nav.pop();
nav.popUntil('/home');
nav.popUntil('/home', inclusive: true);

Query parameters #

Query parameters are parsed automatically and passed as the second argument to the route builder:

NavRoute('/detail', (params, queryParams) =>
    DetailPage(ref: queryParams['ref'] ?? 'direct')),

// Navigate with query params
nav.navigate('/detail?ref=email&page=2');

// Access via back stack entry
nav.currentEntry.queryParams; // {ref: "email", page: "2"}

Path parameters and query parameters are kept separate — no naming conflicts.

Transitions #

Per route:

NavRoute(
  '/detail',
  (_, _) => const DetailPage(),
  enterTransition: (child, animation) =>
      SlideTransition(
        position: Tween(begin: const Offset(1, 0), end: Offset.zero)
            .animate(animation),
        child: child,
      ),
  popExitTransition: (child, animation) =>
      SlideTransition(
        position: Tween(begin: Offset.zero, end: const Offset(1, 0))
            .animate(animation),
        child: child,
      ),
),

Or as NavHost defaults:

NavHost(
  navController: _navController,
  defaultEnterTransition: (child, animation) =>
      FadeTransition(opacity: animation, child: child),
  defaultTransitionDuration: const Duration(milliseconds: 200),
)

Interceptors #

class AuthInterceptor extends NavInterceptor {
  final bool Function() isLoggedIn;
  AuthInterceptor(this.isLoggedIn);

  @override
  String? intercept(String from, String to) {
    if (to.startsWith('/protected') && !isLoggedIn()) return '/login';
    return null; // allow
  }
}

final nav = NavController(
  routes: [...],
  interceptors: [AuthInterceptor(() => userLoggedIn)],
);

Return null to allow, a different path to redirect, or from to block.

Bottom sheets & dialogs #

Declarative (managed by the back stack):

nav.showBottomSheet('/item/1', config: const BottomSheetConfig(
  heightFactor: 0.85,
  showDragHandle: true,
));

nav.showDialog('/confirm');

Imperative (returns a result):

final result = await nav.pushDialogWidget<bool>(
  AlertDialog(
    title: const Text('Confirm'),
    actions: [
      TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('No')),
      TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Yes')),
    ],
  ),
);

Nested NavHost (tab navigation) #

class MainPage extends StatefulWidget { ... }

class _MainPageState extends State<MainPage> {
  final _tabController = NavController(
    initialRoute: '/home',
    routes: [
      NavRoute('/home', (_, _) => const HomePage()),
      NavRoute('/settings', (_, _) => const SettingsPage()),
      NavRoute('/item/:id', (p, _) => DetailPage(id: p['id']!)),
    ],
  );

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: NavHost(navController: _tabController),
      bottomNavigationBar: BottomNavigationBar(
        onTap: (i) => _tabController.switchTo(['/home', '/settings'][i]),
        items: const [...],
      ),
    );
  }
}

Back stack observation #

final nav = context.navController;

nav.currentEntry.path;    // "/item/42"
nav.currentEntry.params;  // {id: "42"}
nav.previousEntry?.path;  // "/home"
nav.backStack;            // List<NavBackStackEntry>
nav.canPop;               // true

NavController extends ChangeNotifier, so you can listen to back stack changes:

nav.addListener(() {
  print('Stack changed: ${nav.backStack.map((e) => e.path)}');
});

navhost supports deep links out of the box via MaterialApp.router. When the OS opens your app with a URL, the route is matched automatically against your defined routes — including path parameters and query parameters.

// These routes handle deep links with no extra configuration
final _navController = NavController(
  routes: [
    NavRoute('/', (_, _) => const HomePage()),
    NavRoute('/item/:id', (params, queryParams) => DetailPage(
      id: params['id']!,
      ref: queryParams['ref'],
    )),
  ],
);

Opening https://example.com/item/42?ref=email navigates to DetailPage(id: '42', ref: 'email') with the initial route underneath in the back stack, so the back button takes the user to /. Query parameters are fully preserved from the deep link URL.

Android setup #

Add intent filters to android/app/src/main/AndroidManifest.xml:

<activity ...>
  <!-- Deep links -->
  <intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="myapp" />
  </intent-filter>

  <!-- App links (https) -->
  <intent-filter android:autoVerify="true">
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="https" android:host="example.com" />
  </intent-filter>
</activity>

iOS setup #

Add URL schemes and associated domains in ios/Runner/Info.plist:

<!-- Custom URL scheme -->
<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>myapp</string>
    </array>
  </dict>
</array>

For universal links, add the associated domain in Xcode under Signing & Capabilities > Associated Domains:

applinks:example.com
# Android
adb shell am start -a android.intent.action.VIEW -d "myapp://item/42"

# iOS
xcrun simctl openurl booted "myapp://item/42"

Interceptors apply to deep links too — unauthenticated users are redirected before the route is shown:

NavController(
  routes: [...],
  interceptors: [AuthGuard()],
)

If a user opens myapp://protected/settings and AuthGuard redirects to /login, they never see the protected page.

Migrating from other routers #

From GoRouter #

Route definition:

// GoRouter
GoRouter(
  routes: [
    GoRoute(path: '/', builder: (context, state) => const HomePage()),
    GoRoute(path: '/item/:id', builder: (context, state) =>
        DetailPage(id: state.pathParameters['id']!)),
  ],
)

// navhost
NavController(
  routes: [
    NavRoute('/', (_, _) => const HomePage()),
    NavRoute('/item/:id', (params, _) => DetailPage(id: params['id']!)),
  ],
)

Navigation:

// GoRouter
context.go('/item/42');
context.push('/item/42');
context.pop();

// navhost
context.navController.switchTo('/item/42');   // replaces stack (like go)
context.navController.navigate('/item/42');   // pushes (like push)
context.navController.pop();

Redirects:

// GoRouter
GoRouter(redirect: (context, state) {
  if (!isLoggedIn && state.matchedLocation.startsWith('/protected')) {
    return '/login';
  }
  return null;
})

// navhost
class AuthGuard extends NavInterceptor {
  @override
  String? intercept(String from, String to) {
    if (!isLoggedIn && to.startsWith('/protected')) return '/login';
    return null;
  }
}

NavController(
  interceptors: [AuthGuard()],
)

From auto_route #

Route definition:

// auto_route
@AutoRouterConfig()
class AppRouter extends RootStackRouter {
  @override
  List<AutoRoute> get routes => [
    AutoRoute(page: HomeRoute.page, initial: true),
    AutoRoute(page: DetailRoute.page, path: '/item/:id'),
  ];
}

// navhost — no code generation needed
NavController(
  routes: [
    NavRoute('/', (_, _) => const HomePage()),
    NavRoute('/item/:id', (params, _) => DetailPage(id: params['id']!)),
  ],
)

Navigation:

// auto_route
context.router.push(DetailRoute(id: '42'));
context.router.pop();
context.router.replaceAll([HomeRoute()]);

// navhost
context.navController.navigate('/item/42');
context.navController.pop();
context.navController.switchTo('/');

From Navigator 1.0 #

Push and pop:

// Navigator 1.0
Navigator.of(context).push(MaterialPageRoute(
  builder: (_) => DetailPage(id: '42'),
));
Navigator.of(context).pop();

// navhost
context.navController.navigate('/item/42');
context.navController.pop();

Named routes:

// Navigator 1.0
MaterialApp(
  routes: {
    '/': (_) => const HomePage(),
    '/settings': (_) => const SettingsPage(),
  },
)
Navigator.of(context).pushNamed('/settings');

// navhost
NavController(
  routes: [
    NavRoute('/', (_, _) => const HomePage()),
    NavRoute('/settings', (_, _) => const SettingsPage()),
  ],
)
context.navController.navigate('/settings');

From GetX routing #

// GetX
GetMaterialApp(
  getPages: [
    GetPage(name: '/', page: () => HomePage()),
    GetPage(name: '/item/:id', page: () => DetailPage()),
  ],
)
Get.toNamed('/item/42');
Get.back();
Get.offAllNamed('/');

// navhost
NavController(
  routes: [
    NavRoute('/', (_, _) => const HomePage()),
    NavRoute('/item/:id', (params, _) => DetailPage(id: params['id']!)),
  ],
)
context.navController.navigate('/item/42');
context.navController.pop();
context.navController.switchTo('/');

Migration tips #

  • Migrate incrementally — navhost uses MaterialApp.router, so switching the router is a one-time change at the app root
  • Path parameters work the same/item/:id syntax is identical across most routers
  • No code generation — unlike auto_route, navhost routes are defined inline with no build step
  • Transitions carry over — navhost supports per-route and global transitions, same as GoRouter and auto_route

Using with other libraries #

navhost is just a navigation layer — it doesn't impose a state management solution. Your route builders are plain functions that return widgets, so you can wrap them with whatever provider, scope, or DI mechanism you already use.

Provider #

Wrap a route with ChangeNotifierProvider to scope a ViewModel to that route. When the route is popped, the provider is removed from the tree and the ViewModel is disposed automatically:

NavController(
  routes: [
    NavRoute('/', (_, _) => const HomePage()),
    NavRoute('/counter', (_, _) => ChangeNotifierProvider(
      create: (_) => CounterModel(),
      child: const CounterPage(),
    )),
  ],
)
// CounterPage reads the model normally
class CounterPage extends StatelessWidget {
  const CounterPage({super.key});

  @override
  Widget build(BuildContext context) {
    final model = context.watch<CounterModel>();
    return Text('${model.count}');
  }
}

For multiple providers on a single route:

NavRoute('/dashboard', (_, _) => MultiProvider(
  providers: [
    ChangeNotifierProvider(create: (_) => StatsModel()),
    ChangeNotifierProvider(create: (_) => NotificationsModel()),
  ],
  child: const DashboardPage(),
)),

Riverpod #

With Riverpod, state lives outside the widget tree, so routes don't need wrapping. Just use ConsumerWidget or Consumer in your pages:

// Providers defined at the top level
final counterProvider = NotifierProvider<CounterNotifier, int>(
  CounterNotifier.new,
);

// Routes — nothing special needed
NavController(
  routes: [
    NavRoute('/', (_, _) => const HomePage()),
    NavRoute('/counter', (_, _) => const CounterPage()),
  ],
)
class CounterPage extends ConsumerWidget {
  const CounterPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider);
    return Text('$count');
  }
}

For route-scoped state that auto-disposes when the page is removed, use autoDispose:

final counterProvider = NotifierProvider.autoDispose<CounterNotifier, int>(
  CounterNotifier.new,
);

Bloc / Cubit #

Wrap a route with BlocProvider for route-scoped Blocs. The Bloc is created when the route is pushed and closed when it's popped:

NavController(
  routes: [
    NavRoute('/', (_, _) => const HomePage()),
    NavRoute('/counter', (_, _) => BlocProvider(
      create: (_) => CounterCubit(),
      child: const CounterPage(),
    )),
    NavRoute('/item/:id', (params, _) => BlocProvider(
      create: (_) => ItemCubit(id: params['id']!),
      child: const ItemPage(),
    )),
  ],
)
class CounterPage extends StatelessWidget {
  const CounterPage({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocBuilder<CounterCubit, int>(
      builder: (context, count) => Text('$count'),
    );
  }
}

GetX #

Instantiate controllers inside the route builder. Use GetBuilder or Obx in the page:

NavController(
  routes: [
    NavRoute('/', (_, _) => const HomePage()),
    NavRoute('/counter', (_, _) {
      Get.put(CounterController());
      return const CounterPage();
    }),
  ],
)
class CounterPage extends StatelessWidget {
  const CounterPage({super.key});

  @override
  Widget build(BuildContext context) {
    final ctrl = Get.find<CounterController>();
    return Obx(() => Text('${ctrl.count}'));
  }
}

get_it #

Use get_it to resolve dependencies in route builders:

// Registration
final getIt = GetIt.instance;
getIt.registerFactory(() => CounterViewModel());
getIt.registerSingleton(ApiService());

// Routes
NavController(
  routes: [
    NavRoute('/', (_, _) => const HomePage()),
    NavRoute('/counter', (_, _) => CounterPage(
      vm: getIt<CounterViewModel>(),
    )),
    NavRoute('/item/:id', (params, _) => ItemPage(
      vm: getIt<ItemViewModel>(param1: params['id']!),
    )),
  ],
)

Using path and query parameters with providers #

Route builders receive path parameters and query parameters, which you can pass into your providers or ViewModels:

NavRoute('/item/:id', (params, queryParams) => BlocProvider(
  create: (_) => ItemCubit(
    id: params['id']!,
    ref: queryParams['ref'] ?? 'direct',
  ),
  child: const ItemPage(),
)),

Summary #

Library Pattern Scoped to route?
Provider Wrap route with ChangeNotifierProvider Yes — disposed on pop
Riverpod Use ConsumerWidget; add .autoDispose for scoping Auto-dispose optional
Bloc Wrap route with BlocProvider Yes — closed on pop
GetX Call Get.put() in route builder Manual (Get.delete)
get_it Resolve in route builder via getIt<T>() Depends on registration

The general rule: navhost route builders are widget factories — anything you can wrap around a widget in Flutter, you can use inside a route builder.

State management #

Looking for reactive state management? Check out navhost_state — GetX-style .obs reactive values, auto-tracking Obs widgets, and scoped ViewModels that are tied to the route lifecycle. Zero boilerplate, fine-grained rebuilds.

Example #

See the example app for a full showcase of all navigation features.

License #

MIT

1
likes
0
points
619
downloads

Publisher

unverified uploader

Weekly Downloads

A Compose-inspired declarative navigation wrapper for Flutter's Navigator 2.0 with NavController, NavHost, transitions, interceptors, and bottom sheet/dialog support.

Repository (GitHub)
View/report issues

Topics

#navigation #navigator #router #compose

License

unknown (license)

Dependencies

flutter

More

Packages that depend on navhost