Instantly share code, notes, and snippets.
Last active
September 4, 2025 06:38
-
Star
13
(13)
You must be signed in to star a gist -
Fork
1
(1)
You must be signed in to fork a gist
-
-
Save PlugFox/aaa2a1ab4ab71b483b736530ebb03894 to your computer and use it in GitHub Desktop.
A simple declarative navigation system for Flutter.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import 'dart:collection'; | |
| import 'dart:developer'; | |
| import 'package:flutter/foundation.dart'; | |
| import 'package:flutter/material.dart'; | |
| // --- Navigator --- // | |
| /// Type definition for the navigation state. | |
| typedef AppNavigationState = List<AppPage>; | |
| /// {@template navigator} | |
| /// AppNavigator widget. | |
| /// {@endtemplate} | |
| class AppNavigator extends StatefulWidget { | |
| /// {@macro navigator} | |
| AppNavigator({ | |
| required this.pages, | |
| this.guards = const [], | |
| this.observers = const [], | |
| this.transitionDelegate = const DefaultTransitionDelegate<Object?>(), | |
| this.revalidate, | |
| super.key, | |
| }) : assert(pages.isNotEmpty, 'pages cannot be empty'), | |
| controller = null; | |
| /// {@macro navigator} | |
| AppNavigator.controlled({ | |
| required ValueNotifier<AppNavigationState> this.controller, | |
| this.guards = const [], | |
| this.observers = const [], | |
| this.transitionDelegate = const DefaultTransitionDelegate<Object?>(), | |
| this.revalidate, | |
| super.key, | |
| }) : assert(controller.value.isNotEmpty, 'controller cannot be empty'), | |
| pages = controller.value; | |
| /// The [AppNavigatorState] from the closest instance of this class | |
| /// that encloses the given context, if any. | |
| static AppNavigatorState? maybeOf(BuildContext context) => context.findAncestorStateOfType<AppNavigatorState>(); | |
| /// The navigation state from the closest instance of this class | |
| /// that encloses the given context, if any. | |
| static AppNavigationState? stateOf(BuildContext context) => maybeOf(context)?.state; | |
| /// The navigator from the closest instance of this class | |
| /// that encloses the given context, if any. | |
| static NavigatorState? navigatorOf(BuildContext context) => maybeOf(context)?.navigator; | |
| /// Change the pages. | |
| static void change(BuildContext context, AppNavigationState Function(AppNavigationState pages) fn) => | |
| maybeOf(context)?.change(fn); | |
| /// Add a page to the stack. | |
| static void push(BuildContext context, AppPage page) => change(context, (state) => [...state, page]); | |
| /// Pop the last page from the stack. | |
| static void pop(BuildContext context) => change(context, (state) { | |
| if (state.isNotEmpty) state.removeLast(); | |
| return state; | |
| }); | |
| /// Clear the pages to the initial state. | |
| static void reset(BuildContext context, AppPage page) { | |
| final navigator = maybeOf(context); | |
| if (navigator == null) return; | |
| navigator.change((_) => navigator.widget.pages); | |
| } | |
| /// Initial pages to display. | |
| final AppNavigationState pages; | |
| /// The controller to use for the navigator. | |
| final ValueNotifier<AppNavigationState>? controller; | |
| /// Guards to apply to the pages. | |
| final List<AppNavigationState Function(BuildContext context, AppNavigationState state)> guards; | |
| /// Observers to attach to the navigator. | |
| final List<NavigatorObserver> observers; | |
| /// The transition delegate to use for the navigator. | |
| final TransitionDelegate<Object?> transitionDelegate; | |
| /// Revalidate the pages. | |
| final Listenable? revalidate; | |
| @override | |
| State<AppNavigator> createState() => AppNavigatorState(); | |
| } | |
| /// State for widget AppNavigator. | |
| class AppNavigatorState extends State<AppNavigator> with WidgetsBindingObserver { | |
| /// The current [Navigator] state (null if not yet built). | |
| NavigatorState? get navigator => _observer.navigator; | |
| final NavigatorObserver _observer = NavigatorObserver(); | |
| /// The current pages list. | |
| AppNavigationState get state => _state; | |
| late AppNavigationState _state; | |
| List<NavigatorObserver> _observers = const []; | |
| /* #region Lifecycle */ | |
| @override | |
| void initState() { | |
| super.initState(); | |
| _state = widget.pages; | |
| widget.revalidate?.addListener(revalidate); | |
| _observers = <NavigatorObserver>[_observer, ...widget.observers]; | |
| widget.controller?.addListener(_controllerListener); | |
| _controllerListener(); | |
| WidgetsBinding.instance.addObserver(this); | |
| } | |
| @override | |
| void didChangeDependencies() { | |
| super.didChangeDependencies(); | |
| revalidate(); | |
| } | |
| @override | |
| void didUpdateWidget(covariant AppNavigator oldWidget) { | |
| super.didUpdateWidget(oldWidget); | |
| if (!identical(widget.revalidate, oldWidget.revalidate)) { | |
| oldWidget.revalidate?.removeListener(revalidate); | |
| widget.revalidate?.addListener(revalidate); | |
| } | |
| if (!identical(widget.observers, oldWidget.observers)) { | |
| _observers = <NavigatorObserver>[_observer, ...widget.observers]; | |
| } | |
| if (!identical(widget.controller, oldWidget.controller)) { | |
| oldWidget.controller?.removeListener(_controllerListener); | |
| widget.controller?.addListener(_controllerListener); | |
| _controllerListener(); | |
| } | |
| } | |
| @override | |
| void dispose() { | |
| WidgetsBinding.instance.removeObserver(this); | |
| widget.controller?.removeListener(_controllerListener); | |
| widget.revalidate?.removeListener(revalidate); | |
| super.dispose(); | |
| } | |
| /* #endregion */ | |
| @override | |
| Future<bool> didPopRoute() { | |
| if (_state.length < 2) return SynchronousFuture(false); | |
| _onDidRemovePage(_state.last); | |
| return SynchronousFuture(true); | |
| } | |
| void _setStateToController() { | |
| if (widget.controller case ValueNotifier<AppNavigationState> controller) { | |
| controller | |
| ..removeListener(_controllerListener) | |
| ..value = _state | |
| ..addListener(_controllerListener); | |
| } | |
| } | |
| void _controllerListener() { | |
| final controller = widget.controller; | |
| if (controller == null || !mounted) return; | |
| final newValue = controller.value; | |
| if (identical(newValue, _state)) return; | |
| final ctx = context; | |
| final next = widget.guards.fold(newValue.toList(), (s, g) => g(ctx, s)); | |
| if (next.isEmpty || listEquals(next, _state)) { | |
| _setStateToController(); // Revert the controller value. | |
| } else { | |
| _state = UnmodifiableListView<AppPage>(next); | |
| _setStateToController(); | |
| setState(() {}); | |
| } | |
| // --- Navigator --- // | |
| /// Type definition for the navigation state. | |
| typedef AppNavigationState = List<AppPage>; | |
| /// {@template navigator} | |
| /// AppNavigator widget. | |
| /// {@endtemplate} | |
| class AppNavigator extends StatefulWidget { | |
| /// {@macro navigator} | |
| AppNavigator({ | |
| required this.pages, | |
| this.guards = const [], | |
| this.observers = const [], | |
| this.transitionDelegate = const DefaultTransitionDelegate<Object?>(), | |
| this.revalidate, | |
| this.onBackButtonPressed, | |
| super.key, | |
| }) : assert(pages.isNotEmpty, 'pages cannot be empty'), | |
| controller = null; | |
| /// {@macro navigator} | |
| AppNavigator.controlled({ | |
| required ValueNotifier<AppNavigationState> this.controller, | |
| this.guards = const [], | |
| this.observers = const [], | |
| this.transitionDelegate = const DefaultTransitionDelegate<Object?>(), | |
| this.revalidate, | |
| this.onBackButtonPressed, | |
| super.key, | |
| }) : assert(controller.value.isNotEmpty, 'controller cannot be empty'), | |
| pages = controller.value; | |
| /// The [AppNavigatorState] from the closest instance of this class | |
| /// that encloses the given context, if any. | |
| static AppNavigatorState? maybeOf(BuildContext context) => context.findAncestorStateOfType<AppNavigatorState>(); | |
| /// The navigation state from the closest instance of this class | |
| /// that encloses the given context, if any. | |
| static AppNavigationState? stateOf(BuildContext context) => maybeOf(context)?.state; | |
| /// The navigator from the closest instance of this class | |
| /// that encloses the given context, if any. | |
| static NavigatorState? navigatorOf(BuildContext context) => maybeOf(context)?.navigator; | |
| /// Change the pages. | |
| static void change(BuildContext context, AppNavigationState Function(AppNavigationState pages) fn) => | |
| maybeOf(context)?.change(fn); | |
| /// Add a page to the stack. | |
| static void push(BuildContext context, AppPage page) => change(context, (state) => [...state, page]); | |
| /// Pop the last page from the stack. | |
| static void pop(BuildContext context) => change(context, (state) { | |
| if (state.isNotEmpty) state.removeLast(); | |
| return state; | |
| }); | |
| /// Clear the pages to the initial state. | |
| static void reset(BuildContext context, AppPage page) { | |
| final navigator = maybeOf(context); | |
| if (navigator == null) return; | |
| navigator.change((_) => navigator.widget.pages); | |
| } | |
| /// Initial pages to display. | |
| final AppNavigationState pages; | |
| /// The controller to use for the navigator. | |
| final ValueNotifier<AppNavigationState>? controller; | |
| /// Guards to apply to the pages. | |
| final List<AppNavigationState Function(BuildContext context, AppNavigationState state)> guards; | |
| /// Observers to attach to the navigator. | |
| final List<NavigatorObserver> observers; | |
| /// The transition delegate to use for the navigator. | |
| final TransitionDelegate<Object?> transitionDelegate; | |
| /// Revalidate the pages. | |
| final Listenable? revalidate; | |
| /// The callback function that will be called when the back button is pressed. | |
| /// | |
| /// It must return a boolean with true if this navigator will handle the request; | |
| /// otherwise, return a boolean with false. | |
| /// | |
| /// Also you can mutate the [AppNavigationState] to change the navigation stack. | |
| final ({AppNavigationState state, bool handled}) Function(AppNavigationState state)? onBackButtonPressed; | |
| @override | |
| State<AppNavigator> createState() => AppNavigatorState(); | |
| } | |
| /// State for widget AppNavigator. | |
| class AppNavigatorState extends State<AppNavigator> with WidgetsBindingObserver { | |
| /// The current [Navigator] state (null if not yet built). | |
| NavigatorState? get navigator => _observer.navigator; | |
| final NavigatorObserver _observer = NavigatorObserver(); | |
| /// The current pages list. | |
| AppNavigationState get state => _state; | |
| late AppNavigationState _state; | |
| List<NavigatorObserver> _observers = const []; | |
| /* #region Lifecycle */ | |
| @override | |
| void initState() { | |
| super.initState(); | |
| _state = widget.pages; | |
| widget.revalidate?.addListener(revalidate); | |
| _observers = <NavigatorObserver>[_observer, ...widget.observers]; | |
| widget.controller?.addListener(_controllerListener); | |
| _controllerListener(); | |
| WidgetsBinding.instance.addObserver(this); | |
| } | |
| @override | |
| void didChangeDependencies() { | |
| super.didChangeDependencies(); | |
| revalidate(); | |
| } | |
| @override | |
| void didUpdateWidget(covariant AppNavigator oldWidget) { | |
| super.didUpdateWidget(oldWidget); | |
| if (!identical(widget.revalidate, oldWidget.revalidate)) { | |
| oldWidget.revalidate?.removeListener(revalidate); | |
| widget.revalidate?.addListener(revalidate); | |
| } | |
| if (!identical(widget.observers, oldWidget.observers)) { | |
| _observers = <NavigatorObserver>[_observer, ...widget.observers]; | |
| } | |
| if (!identical(widget.controller, oldWidget.controller)) { | |
| oldWidget.controller?.removeListener(_controllerListener); | |
| widget.controller?.addListener(_controllerListener); | |
| _controllerListener(); | |
| } | |
| } | |
| @override | |
| void dispose() { | |
| WidgetsBinding.instance.removeObserver(this); | |
| widget.controller?.removeListener(_controllerListener); | |
| widget.revalidate?.removeListener(revalidate); | |
| super.dispose(); | |
| } | |
| /* #endregion */ | |
| @override | |
| Future<bool> didPopRoute() { | |
| // If the back button handler is defined, call it. | |
| final backButtonHandler = widget.onBackButtonPressed; | |
| if (backButtonHandler != null) { | |
| final result = backButtonHandler(_state.toList()); | |
| change((pages) => result.state); | |
| return SynchronousFuture(result.handled); | |
| } | |
| // Otherwise, handle the back button press with the default behavior. | |
| if (_state.length < 2) return SynchronousFuture(false); | |
| _onDidRemovePage(_state.last); | |
| return SynchronousFuture(true); | |
| } | |
| void _setStateToController() { | |
| if (widget.controller case ValueNotifier<AppNavigationState> controller) { | |
| controller | |
| ..removeListener(_controllerListener) | |
| ..value = _state | |
| ..addListener(_controllerListener); | |
| } | |
| } | |
| void _controllerListener() { | |
| final controller = widget.controller; | |
| if (controller == null || !mounted) return; | |
| final newValue = controller.value; | |
| if (identical(newValue, _state)) return; | |
| final ctx = context; | |
| final next = widget.guards.fold(newValue.toList(), (s, g) => g(ctx, s)); | |
| if (next.isEmpty || listEquals(next, _state)) { | |
| _setStateToController(); // Revert the controller value. | |
| } else { | |
| _state = UnmodifiableListView<AppPage>(next); | |
| _setStateToController(); | |
| setState(() {}); | |
| } | |
| } | |
| /// Revalidate the pages. | |
| void revalidate() { | |
| if (!mounted) return; | |
| final ctx = context; | |
| final next = widget.guards.fold(_state.toList(), (s, g) => g(ctx, s)); | |
| if (next.isEmpty || listEquals(next, _state)) return; | |
| _state = UnmodifiableListView<AppPage>(next); | |
| _setStateToController(); | |
| setState(() {}); | |
| } | |
| /// Change the pages. | |
| void change(AppNavigationState Function(AppNavigationState pages) fn) { | |
| final prev = _state.toList(); | |
| var next = fn(prev); | |
| if (next.isEmpty) return; | |
| if (!mounted) return; | |
| final ctx = context; | |
| next = widget.guards.fold(next, (s, g) => g(ctx, s)); | |
| if (next.isEmpty || listEquals(next, _state)) return; | |
| _state = UnmodifiableListView<AppPage>(next); | |
| _setStateToController(); | |
| setState(() {}); | |
| } | |
| /// Called when a page is removed from the stack. | |
| void _onDidRemovePage(Page<Object?> page) { | |
| change((pages) => pages..removeWhere((p) => p.key == page.key)); | |
| } | |
| @override | |
| Widget build(BuildContext context) => Navigator( | |
| pages: _state, | |
| reportsRouteUpdateToEngine: false, | |
| transitionDelegate: widget.transitionDelegate, | |
| onDidRemovePage: _onDidRemovePage, | |
| observers: _observers, | |
| ); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import 'package:flutter/material.dart'; | |
| /// Type definition for the page. | |
| @immutable | |
| sealed class AppPage extends MaterialPage<void> { | |
| const AppPage({ | |
| required String super.name, | |
| required Map<String, Object?>? super.arguments, | |
| required super.child, | |
| required LocalKey super.key, | |
| }); | |
| @override | |
| String get name => super.name ?? 'Unknown'; | |
| abstract final Set<String> tags; | |
| @override | |
| Map<String, Object?> get arguments => switch (super.arguments) { | |
| Map<String, Object?> args when args.isNotEmpty => args, | |
| _ => const <String, Object?>{}, | |
| }; | |
| @override | |
| int get hashCode => key.hashCode; | |
| @override | |
| bool operator ==(Object other) => identical(this, other) || other is AppPage && key == other.key; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment