Skip to content

Instantly share code, notes, and snippets.

@definev
Last active November 27, 2025 06:51
Show Gist options
  • Select an option

  • Save definev/2ef75cb73b8021723969c3d032fb1875 to your computer and use it in GitHub Desktop.

Select an option

Save definev/2ef75cb73b8021723969c3d032fb1875 to your computer and use it in GitHub Desktop.
Complete cloned with same feature set as NavigationStack like SwiftUI in Flutter, using Navigator Page API
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart'; // For kIsWeb
// ==============================================================================
// 1. THE MICRO-FRAMEWORK (Copy this to your core library)
// ==============================================================================
/// A state object representing the active path of the navigation.
/// Acts as the "Source of Truth" for the navigation stack.
class NavigationPath<T> extends ChangeNotifier {
final List<T> _stack = [];
/// Create with an optional initial route
NavigationPath([T? initial]) {
if (initial != null) _stack.add(initial);
}
/// Read-only view of the stack
List<T> get stack => List.unmodifiable(_stack);
// --- MUTATION METHODS (Like manipulating a Swift Array) ---
void push(T route) {
_stack.add(route);
notifyListeners();
}
/// Push multiple routes at once (e.g., restoring a history path)
void pushAll(List<T> routes) {
_stack.addAll(routes);
notifyListeners();
}
/// Smart Push: If the route exists in the stack, move it to the top.
/// If it doesn't exist, push it.
/// Useful for deep linking where you don't want duplicate screens.
void pushOrBringToFront(T route) {
if (_stack.contains(route)) {
_stack.remove(route);
}
_stack.add(route);
notifyListeners();
}
/// Replace a specific route in the stack with a new one.
/// Used primarily for Redirects (e.g. Protected -> Login).
void replace(T oldRoute, T newRoute) {
final index = _stack.indexOf(oldRoute);
if (index != -1) {
_stack[index] = newRoute;
notifyListeners();
}
}
void pop() {
if (_stack.isNotEmpty) {
_stack.removeLast();
notifyListeners();
}
}
void popToRoot() {
_stack.clear();
if (_stack.isNotEmpty) {
final root = _stack.first;
_stack.clear();
_stack.add(root);
notifyListeners();
}
}
/// Deep Link / Reset capability
void setStack(List<T> newStack) {
_stack.clear();
_stack.addAll(newStack);
notifyListeners();
}
}
/// Defines how the route is presented visually.
abstract class PresentationStyle {
const PresentationStyle();
/// Creates the specific Page route for Navigator
Page<dynamic> createPage(Widget child, dynamic routeData, String? title);
// --- STANDARD STYLES ---
static const PresentationStyle push = _PushStyle();
static const PresentationStyle modal = _ModalStyle();
static const PresentationStyle cupertino = _CupertinoStyle();
static const PresentationStyle instant = _InstantStyle();
/// Create a custom transition on the fly
static PresentationStyle custom({
required RouteTransitionsBuilder transitionsBuilder,
Duration duration = const Duration(milliseconds: 300),
bool opaque = true,
}) {
return _CustomTransitionStyle(transitionsBuilder, duration, opaque);
}
}
// --- Implementation Details ---
class _PushStyle extends PresentationStyle {
const _PushStyle();
@override
Page<dynamic> createPage(Widget child, dynamic routeData, String? title) {
return MaterialPage(
child: child,
key: ValueKey(routeData),
name: title,
arguments: routeData,
);
}
}
class _ModalStyle extends PresentationStyle {
const _ModalStyle();
@override
Page<dynamic> createPage(Widget child, dynamic routeData, String? title) {
return MaterialPage(
child: child,
key: ValueKey(routeData),
name: title,
arguments: routeData,
fullscreenDialog: true,
);
}
}
class _CupertinoStyle extends PresentationStyle {
const _CupertinoStyle();
@override
Page<dynamic> createPage(Widget child, dynamic routeData, String? title) {
return CupertinoPage(
child: child,
key: ValueKey(routeData),
name: title,
arguments: routeData,
);
}
}
class _InstantStyle extends PresentationStyle {
const _InstantStyle();
@override
Page<dynamic> createPage(Widget child, dynamic routeData, String? title) {
return _CustomPage(
child: child,
key: ValueKey(routeData),
name: title,
arguments: routeData,
opaque: true,
transitionDuration: Duration.zero,
transitionsBuilder: (_, __, ___, child) => child,
);
}
}
class _CustomTransitionStyle extends PresentationStyle {
final RouteTransitionsBuilder transitionsBuilder;
final Duration duration;
final bool opaque;
const _CustomTransitionStyle(this.transitionsBuilder, this.duration, this.opaque);
@override
Page<dynamic> createPage(Widget child, dynamic routeData, String? title) {
return _CustomPage(
child: child,
key: ValueKey(routeData),
name: title,
arguments: routeData,
transitionsBuilder: transitionsBuilder,
transitionDuration: duration,
opaque: opaque,
);
}
}
class _CustomPage extends Page<dynamic> {
final Widget child;
final RouteTransitionsBuilder transitionsBuilder;
final Duration transitionDuration;
final bool opaque;
const _CustomPage({
required this.child,
required this.transitionsBuilder,
required this.transitionDuration,
required this.opaque,
super.key,
super.name,
super.arguments,
});
@override
Route<dynamic> createRoute(BuildContext context) {
return PageRouteBuilder(
settings: this,
pageBuilder: (context, animation, secondaryAnimation) => child,
transitionsBuilder: transitionsBuilder,
transitionDuration: transitionDuration,
opaque: opaque,
reverseTransitionDuration: transitionDuration,
);
}
}
/// A declarative definition of where a route leads.
class Destination {
final WidgetBuilder builder;
final PresentationStyle style;
final String? title;
/// Optional callback to control popping behavior.
final bool Function()? onPop;
/// Optional Async Redirect.
/// Returns [null] to stay on this route.
/// Returns a new [RouteObject] to replace the current route with.
final Future<dynamic> Function(BuildContext context)? redirect;
/// Widget to show while [redirect] is running.
/// Defaults to a Center(CircularProgressIndicator).
final WidgetBuilder? placeholder;
const Destination({
required this.builder,
this.style = PresentationStyle.push,
this.title,
this.onPop,
this.redirect,
this.placeholder,
});
}
/// The Widget that binds the UI to the NavigationPath.
class NavigationStack<T> extends StatelessWidget {
final NavigationPath<T> path;
final Destination Function(T route) destinationBuilder;
const NavigationStack({
super.key,
required this.path,
required this.destinationBuilder,
});
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: path,
builder: (context, _) {
return Navigator(
onDidRemovePage: (page) {
path.pop();
},
pages: path.stack.map((routeData) {
final dest = destinationBuilder(routeData);
// 1. Build the base content
Widget child;
// 2. Wrap in Redirect Guard if needed
if (dest.redirect != null) {
child = _RedirectGuard<T>(
path: path,
route: routeData,
validator: dest.redirect!,
placeholder: dest.placeholder ?? (_) => const Scaffold(
body: Center(child: CircularProgressIndicator()),
),
childBuilder: dest.builder,
);
} else {
child = dest.builder(context);
}
// 3. Wrap in PopScope (Back Button Interception)
child = PopScope(
canPop: dest.onPop == null,
onPopInvokedWithResult: (didPop, result) {
if (didPop) return;
if (dest.onPop != null) {
final shouldPop = dest.onPop!();
if (shouldPop) {
path.pop();
}
}
},
child: child,
);
// 4. Create Page
return dest.style.createPage(child, routeData, dest.title);
}).toList(),
);
},
);
}
}
/// Internal widget to handle async redirect logic
class _RedirectGuard<T> extends StatefulWidget {
final NavigationPath<T> path;
final T route;
final Future<dynamic> Function(BuildContext context) validator;
final WidgetBuilder placeholder;
final WidgetBuilder childBuilder;
const _RedirectGuard({
required this.path,
required this.route,
required this.validator,
required this.placeholder,
required this.childBuilder,
});
@override
State<_RedirectGuard<T>> createState() => _RedirectGuardState<T>();
}
class _RedirectGuardState<T> extends State<_RedirectGuard<T>> {
bool _isLoading = true;
@override
void initState() {
super.initState();
_checkRedirect();
}
Future<void> _checkRedirect() async {
// Run the async check
final result = await widget.validator(context);
if (!mounted) return;
if (result != null && result is T) {
// REDIRECT: Replace the current route in the stack with the new result
widget.path.replace(widget.route, result);
} else {
// SUCCESS: Show the content
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return widget.placeholder(context);
}
return widget.childBuilder(context);
}
}
// ==============================================================================
// 2. DOMAIN LAYER (Routes)
// ==============================================================================
// --- Root Level Routes ---
sealed class RootRoute {
const RootRoute();
}
class LoginRoute extends RootRoute {
const LoginRoute();
}
class HomeShellRoute extends RootRoute {
const HomeShellRoute();
}
class SettingsRoute extends RootRoute {
const SettingsRoute();
}
// --- Nested Level Routes (The Feed) ---
sealed class FeedRoute {
const FeedRoute();
}
class ItemListRoute extends FeedRoute {
const ItemListRoute();
}
class ItemDetailRoute extends FeedRoute {
final int id;
const ItemDetailRoute(this.id);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ItemDetailRoute && runtimeType == other.runtimeType && id == other.id;
@override
int get hashCode => id.hashCode;
}
// --- Nested Level Routes (The Profile) ---
sealed class ProfileInternalRoute {
const ProfileInternalRoute();
}
class ProfileHomeRoute extends ProfileInternalRoute {
const ProfileHomeRoute();
}
// ==============================================================================
// 3. COORDINATOR (State Management)
// ==============================================================================
class AppCoordinator extends ChangeNotifier {
// 1. The Main Stack
final rootPath = NavigationPath<RootRoute>(const LoginRoute());
// 2. The Feed Tab Stack
final feedPath = NavigationPath<FeedRoute>(const ItemListRoute());
// 3. The Profile Tab Stack (New)
final profilePath = NavigationPath<ProfileInternalRoute>(const ProfileHomeRoute());
// 4. Tab Management
int _selectedTabIndex = 0;
int get selectedTabIndex => _selectedTabIndex;
AppCoordinator() {
// Listen to changes to notify the Router (for URL updates)
rootPath.addListener(notifyListeners);
feedPath.addListener(notifyListeners);
profilePath.addListener(notifyListeners);
}
@override
void dispose() {
rootPath.removeListener(notifyListeners);
feedPath.removeListener(notifyListeners);
profilePath.removeListener(notifyListeners);
super.dispose();
}
// --- ACTIONS ---
void selectTab(int index) {
_selectedTabIndex = index;
notifyListeners();
}
void login() {
rootPath.setStack([const HomeShellRoute()]);
}
void logout() {
feedPath.setStack([const ItemListRoute()]);
profilePath.setStack([const ProfileHomeRoute()]);
_selectedTabIndex = 0;
rootPath.setStack([const LoginRoute()]);
}
void openSettings() {
rootPath.push(const SettingsRoute());
}
void openDetail(int id) {
if (_selectedTabIndex != 0) {
_selectedTabIndex = 0;
notifyListeners();
}
feedPath.push(ItemDetailRoute(id));
}
void deepLinkToItem(int id) {
rootPath.pushOrBringToFront(const HomeShellRoute());
_selectedTabIndex = 0; // Switch to Feed Tab
feedPath.pushOrBringToFront(ItemDetailRoute(id));
notifyListeners();
}
void deepLinkToProfile() {
rootPath.pushOrBringToFront(const HomeShellRoute());
_selectedTabIndex = 1; // Switch to Profile Tab
notifyListeners();
}
// --- URI CALCULATIONS (State -> URL) ---
Uri get currentUri {
return switch ((rootPath.stack.last, _selectedTabIndex, feedPath.stack.last)) {
(LoginRoute(), _, _) => Uri(path: '/login'),
(SettingsRoute(), _, _) => Uri(path: '/settings'),
(HomeShellRoute(), 1, _) => Uri(path: '/profile'),
(HomeShellRoute(), 0, ItemDetailRoute(id: final id)) => Uri(path: '/item/$id'),
(HomeShellRoute(), 0, _) => Uri(path: '/'),
(_, _, _) => Uri(path: '/'),
};
}
// --- URI RESTORATION (URL -> State) ---
void recoverFromUri(Uri uri) {
switch (uri.pathSegments) {
case ['login']:
logout();
case ['settings']:
rootPath.setStack([const HomeShellRoute(), const SettingsRoute()]);
case ['profile']:
deepLinkToProfile();
case ['item', final idStr] when int.tryParse(idStr) != null:
deepLinkToItem(int.parse(idStr));
case _:
login();
}
}
}
final coordinator = AppCoordinator();
// ==============================================================================
// 4. ROUTER IMPLEMENTATION (URL Handling)
// ==============================================================================
class AppRouteParser extends RouteInformationParser<Uri> {
@override
Future<Uri> parseRouteInformation(RouteInformation routeInformation) async {
return routeInformation.uri;
}
@override
RouteInformation? restoreRouteInformation(Uri configuration) {
return RouteInformation(uri: configuration);
}
}
class AppRouterDelegate extends RouterDelegate<Uri>
with ChangeNotifier, PopNavigatorRouterDelegateMixin<Uri> {
@override
final GlobalKey<NavigatorState> navigatorKey;
AppRouterDelegate() : navigatorKey = GlobalKey<NavigatorState>() {
coordinator.addListener(notifyListeners);
}
@override
void dispose() {
coordinator.removeListener(notifyListeners);
super.dispose();
}
@override
Future<void> setNewRoutePath(Uri configuration) async {
coordinator.recoverFromUri(configuration);
}
@override
Uri? get currentConfiguration => coordinator.currentUri;
@override
Widget build(BuildContext context) {
return const AppScaffold();
}
}
// ==============================================================================
// 5. FLUTTER UI
// ==============================================================================
void main() {
runApp(const MainApp());
}
class MainApp extends StatefulWidget {
const MainApp({super.key});
@override
State<MainApp> createState() => _MainAppState();
}
class _MainAppState extends State<MainApp> {
final _routerDelegate = AppRouterDelegate();
final _routeInformationParser = AppRouteParser();
@override
Widget build(BuildContext context) {
return MaterialApp.router(
debugShowCheckedModeBanner: false,
title: 'SwiftUI Style Nav',
routerDelegate: _routerDelegate,
routeInformationParser: _routeInformationParser,
);
}
}
class AppScaffold extends StatelessWidget {
const AppScaffold({super.key});
@override
Widget build(BuildContext context) {
// LEVEL 1: ROOT NAVIGATION
return NavigationStack<RootRoute>(
path: coordinator.rootPath,
destinationBuilder: (route) {
return switch (route) {
LoginRoute() => Destination(
builder: (_) => const LoginScreen(),
),
HomeShellRoute() => Destination(
builder: (_) => const HomeShellScreen(),
),
SettingsRoute() => Destination(
style: PresentationStyle.custom(
transitionsBuilder: (context, animation, secondaryAnimation, child) {
const begin = Offset(0.0, 1.0);
const end = Offset.zero;
const curve = Curves.ease;
var tween = Tween(begin: begin, end: end).chain(CurveTween(curve: curve));
return SlideTransition(position: animation.drive(tween), child: child);
},
duration: const Duration(milliseconds: 500),
),
builder: (_) => const SettingsScreen(),
),
};
},
);
}
}
// --- Screens ---
class LoginScreen extends StatelessWidget {
const LoginScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.blueGrey.shade50,
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.lock_open, size: 64, color: Colors.blueGrey),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () => coordinator.login(),
child: const Text("Login"),
),
const SizedBox(height: 10),
const Text(
"Or try navigating to /item/123 in browser URL",
style: TextStyle(color: Colors.grey, fontSize: 12)
),
],
),
),
);
}
}
class HomeShellScreen extends StatelessWidget {
const HomeShellScreen({super.key});
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: coordinator,
builder: (context, _) {
return Scaffold(
appBar: AppBar(
title: const Text("My App"),
actions: [
IconButton(
icon: const Icon(Icons.settings),
onPressed: () => coordinator.openSettings(),
)
],
),
body: Row(
children: [
NavigationRail(
selectedIndex: coordinator.selectedTabIndex,
onDestinationSelected: coordinator.selectTab,
labelType: NavigationRailLabelType.all,
destinations: const [
NavigationRailDestination(icon: Icon(Icons.list), label: Text("Feed")),
NavigationRailDestination(icon: Icon(Icons.person), label: Text("Profile")),
],
),
const VerticalDivider(width: 1),
Expanded(
child: IndexedStack(
index: coordinator.selectedTabIndex,
children: [
// TAB 0: FEED STACK
NavigationStack<FeedRoute>(
path: coordinator.feedPath,
destinationBuilder: (route) {
return switch (route) {
ItemListRoute() => Destination(
builder: (_) => const FeedListScreen(),
),
ItemDetailRoute(id: final id) => Destination(
builder: (_) => ItemDetailScreen(id: id),
// DEMO: ASYNC REDIRECT FOR ITEM #5
redirect: id == 5
? (context) async {
// Simulate Network Check
await Future.delayed(const Duration(seconds: 2));
// Fail -> Redirect to Access Denied (Item 403)
return const ItemDetailRoute(403);
}
: null,
// Custom Loader while checking
placeholder: (_) => const Scaffold(
body: Center(child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text("Checking Premium Status..."),
],
)),
),
// Existing Pop Interception
onPop: () {
if (id == 403) return true; // Allow exit from denied page
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text("Are you sure?"),
content: const Text("Do you want to leave this item?"),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("Stay")
),
TextButton(
onPressed: () {
Navigator.pop(context);
coordinator.feedPath.pop();
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text("Leave")
),
],
),
);
return false;
},
),
};
},
),
// TAB 1: PROFILE STACK
NavigationStack<ProfileInternalRoute>(
path: coordinator.profilePath,
destinationBuilder: (route) {
return switch (route) {
ProfileHomeRoute() => Destination(
builder: (_) => const ProfileScreen(),
),
};
},
),
],
),
),
],
),
);
}
);
}
}
class FeedListScreen extends StatelessWidget {
const FeedListScreen({super.key});
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: 20,
itemBuilder: (context, index) {
return ListTile(
title: Text(index == 5 ? "Item #5 (Premium/Locked)" : "Item #$index"),
subtitle: Text(index == 5
? "Tap to test Async Redirect logic"
: "Tap to view details"),
leading: index == 5 ? const Icon(Icons.lock, color: Colors.orange) : null,
trailing: const Icon(Icons.chevron_right),
onTap: () => coordinator.openDetail(index),
);
},
);
}
}
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.person, size: 80, color: Colors.indigo),
const SizedBox(height: 20),
const Text("User Profile", style: TextStyle(fontSize: 24)),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () => coordinator.logout(),
style: ElevatedButton.styleFrom(foregroundColor: Colors.red),
child: const Text("Logout"),
),
],
),
);
}
}
class ItemDetailScreen extends StatelessWidget {
final int id;
const ItemDetailScreen({super.key, required this.id});
@override
Widget build(BuildContext context) {
// Special UI for the Access Denied page
if (id == 403) {
return Scaffold(
backgroundColor: Colors.red.shade50,
appBar: AppBar(title: const Text("Access Denied"), backgroundColor: Colors.red),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.block, size: 64, color: Colors.red),
const SizedBox(height: 16),
const Text("You do not have permission.", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => coordinator.feedPath.pop(),
child: const Text("Go Back"),
)
],
),
),
);
}
return Scaffold(
appBar: AppBar(title: Text("Item $id")),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text("Details for ID: $id", style: const TextStyle(fontSize: 24)),
const Padding(
padding: EdgeInsets.all(16.0),
child: Text(
"Try going back!\nA dialog should appear.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () => coordinator.deepLinkToItem(999),
style: ElevatedButton.styleFrom(backgroundColor: Colors.orange),
child: const Text("Deep Link to Item #999"),
)
],
),
),
);
}
}
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Settings"), automaticallyImplyLeading: false),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text("This is a Custom Slide Transition"),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () => coordinator.logout(),
style: ElevatedButton.styleFrom(foregroundColor: Colors.red),
child: const Text("Logout"),
),
TextButton(
// Simple pop via coordinator
onPressed: () => coordinator.rootPath.pop(),
child: const Text("Close"),
)
],
),
),
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment