Last active
March 14, 2025 21:04
-
-
Save PlugFox/aaa2a1ab4ab71b483b736530ebb03894 to your computer and use it in GitHub Desktop.
A simple declarative navigation system for Flutter.
This file contains 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
/* | |
* Declarative Navigation | |
* A simple declarative navigation system for Flutter. | |
* https://gist.github.com/PlugFox/aaa2a1ab4ab71b483b736530ebb03894 | |
* https://dartpad.dev?id=aaa2a1ab4ab71b483b736530ebb03894 | |
* Mike Matiunin <[email protected]>, 14 March 2025 | |
*/ | |
import 'dart:async'; | |
import 'dart:collection'; | |
import 'package:flutter/foundation.dart'; | |
import 'package:flutter/material.dart'; | |
/// Type definition for the navigation state. | |
typedef NavigationState = List<Page<Object?>>; | |
/// {@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<NavigationState> 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 NavigationState? 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, | |
NavigationState Function(NavigationState pages) fn, | |
) => maybeOf(context)?.change(fn); | |
/// Add a page to the stack. | |
static void push(BuildContext context, Page<Object?> 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, Page<Object?> page) { | |
final navigator = maybeOf(context); | |
if (navigator == null) return; | |
navigator.change((_) => navigator.widget.pages); | |
} | |
/// Initial pages to display. | |
final NavigationState pages; | |
/// The controller to use for the navigator. | |
final ValueNotifier<NavigationState>? controller; | |
/// Guards to apply to the pages. | |
final List<NavigationState Function(NavigationState)> 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> { | |
/// The current [Navigator] state (null if not yet built). | |
NavigatorState? get navigator => _observer.navigator; | |
/// The current pages list. | |
NavigationState get state => _state; | |
late NavigationState _state; | |
final NavigatorObserver _observer = NavigatorObserver(); | |
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(); | |
} | |
@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() { | |
super.dispose(); | |
widget.controller?.removeListener(_controllerListener); | |
widget.revalidate?.removeListener(revalidate); | |
} | |
/* #endregion */ | |
void _setStateToController() { | |
if (widget.controller case ValueNotifier<NavigationState> controller) { | |
controller | |
..removeListener(_controllerListener) | |
..value = _state | |
..addListener(_controllerListener); | |
} | |
} | |
void _controllerListener() { | |
final controller = widget.controller; | |
if (controller == null) return; | |
final newValue = controller.value; | |
if (identical(newValue, _state)) return; | |
final next = widget.guards.fold(newValue.toList(), (s, g) => g(s)); | |
if (next.isEmpty || listEquals(next, _state)) { | |
_setStateToController(); // Revert the controller value. | |
} else { | |
_state = UnmodifiableListView<Page<Object?>>(next); | |
_setStateToController(); | |
setState(() {}); | |
} | |
} | |
/// Revalidate the pages. | |
void revalidate() { | |
final next = widget.guards.fold(_state.toList(), (s, g) => g(s)); | |
if (next.isEmpty || listEquals(next, _state)) return; | |
_state = UnmodifiableListView<Page<Object?>>(next); | |
_setStateToController(); | |
setState(() {}); | |
} | |
/// Change the pages. | |
void change(NavigationState Function(NavigationState pages) fn) { | |
final prev = _state.toList(); | |
var next = fn(prev); | |
if (next.isEmpty) return; | |
next = widget.guards.fold(next, (s, g) => g(s)); | |
if (next.isEmpty || listEquals(next, _state)) return; | |
_state = UnmodifiableListView<Page<Object?>>(next); | |
_setStateToController(); | |
setState(() {}); | |
} | |
void _onDidRemovePage(Page<Object?> page) => | |
change((pages) => pages..remove(page)); | |
@override | |
Widget build(BuildContext context) => Navigator( | |
pages: _state, | |
reportsRouteUpdateToEngine: false, | |
transitionDelegate: widget.transitionDelegate, | |
onDidRemovePage: _onDidRemovePage, | |
observers: _observers, | |
); | |
} | |
// --- Example --- // | |
void main() => runZonedGuarded<void>( | |
() => runApp(const App()), | |
(error, stackTrace) => | |
print('Top level exception: $error'), // ignore: avoid_print | |
); | |
/// {@template app} | |
/// App widget. | |
/// {@endtemplate} | |
class App extends StatefulWidget { | |
/// {@macro app} | |
const App({super.key}); | |
@override | |
State<App> createState() => _AppState(); | |
} | |
class _AppState extends State<App> { | |
final GlobalKey<State<StatefulWidget>> _preserveKey = | |
GlobalKey<State<StatefulWidget>>(); | |
@override | |
Widget build(BuildContext context) => MaterialApp( | |
title: 'Declarative Navigation', | |
debugShowCheckedModeBanner: false, | |
builder: | |
(context, _) => AppNavigator( | |
key: _preserveKey, | |
pages: const [MaterialPage<void>(child: HomeScreen())], | |
guards: [ | |
(pages) => | |
pages.length > 1 | |
? pages | |
: [const MaterialPage(child: HomeScreen())], | |
], | |
), | |
); | |
} | |
/// Just for example, as one of possible ways to | |
/// represent the pages/routes in the app. | |
enum Routes { | |
home, | |
settings; | |
const Routes(); | |
/// Converts the route to a [MaterialPage]. | |
Page<Object?> page({Map<String, Object?>? arguments, LocalKey? key}) => | |
MaterialPage<void>( | |
name: name, | |
arguments: arguments, | |
key: switch ((key, arguments)) { | |
(LocalKey key, _) => key, | |
(_, Map<String, Object?> arguments) => ValueKey( | |
'$name#${shortHash(arguments)}', | |
), | |
_ => ValueKey<String>(name), | |
}, | |
child: switch (this) { | |
Routes.home => const HomeScreen(), | |
Routes.settings => const SettingsScreen(), | |
}, | |
); | |
} | |
/// {@template home_screen} | |
/// HomeScreen widget. | |
/// {@endtemplate} | |
class HomeScreen extends StatelessWidget { | |
/// {@macro home_screen} | |
const HomeScreen({super.key}); | |
@override | |
Widget build(BuildContext context) => Scaffold( | |
appBar: AppBar( | |
title: const Text('Home'), | |
actions: [ | |
IconButton( | |
icon: const Icon(Icons.settings), | |
onPressed: () => AppNavigator.push(context, Routes.settings.page()), | |
), | |
], | |
), | |
body: const SafeArea(child: Center(child: Text('Home'))), | |
); | |
} | |
/// {@template settings_screen} | |
/// SettingsScreen widget. | |
/// {@endtemplate} | |
class SettingsScreen extends StatelessWidget { | |
/// {@macro settings_screen} | |
const SettingsScreen({super.key}); | |
@override | |
Widget build(BuildContext context) => Scaffold( | |
appBar: AppBar( | |
leading: IconButton( | |
icon: const Icon(Icons.arrow_back), | |
onPressed: | |
() => AppNavigator.change( | |
context, | |
(state) => | |
state..removeWhere((p) => p.name == Routes.settings.name), | |
), | |
), | |
title: const Text('Settings'), | |
), | |
body: const SafeArea(child: Center(child: Text('Settings'))), | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment