Skip to content

Instantly share code, notes, and snippets.

@PlugFox
Last active March 14, 2025 21:04
Show Gist options
  • Save PlugFox/aaa2a1ab4ab71b483b736530ebb03894 to your computer and use it in GitHub Desktop.
Save PlugFox/aaa2a1ab4ab71b483b736530ebb03894 to your computer and use it in GitHub Desktop.
A simple declarative navigation system for Flutter.
/*
* 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