I am trying to build my own router similar to go_router, based on flutter_bloc.
What I am trying to achieve is very similar to this guide that inspired the minimal implementation below:
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
void main() {
final NavigationCubit navigationCubit = NavigationCubit(
[PageConfiguration(uri: Uri.parse("/"))],
);
runApp(MyApp(navigationCubit));
}
class MyApp extends StatelessWidget {
final NavigationCubit _navigationCubit;
const MyApp(this._navigationCubit, {super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => _navigationCubit,
child: MaterialApp.router(
title: "My App",
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
routeInformationParser: MyRouteInformationParser(),
routerDelegate: MyRouterDelegate(_navigationCubit),
),
);
}
}
class MainPage extends MyPage {
@override
Widget build(BuildContext context) {
return const MainPageWidget();
}
}
class MainPageWidget extends StatelessWidget {
const MainPageWidget({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: Text(
'Home Page',
),
),
);
}
}
class MyRouteInformationParser
extends RouteInformationParser<PageConfiguration> {
@override
Future<PageConfiguration> parseRouteInformation(
RouteInformation routeInformation) async {
final Uri path = routeInformation.uri;
PageConfiguration config = PageConfiguration(uri: path);
return config;
}
/// Updates the URL bar with the latest URL from the app.
///
/// Note: This is only useful when running on the web.
@override
RouteInformation? restoreRouteInformation(PageConfiguration configuration) {
return RouteInformation(uri: configuration.uri);
}
}
class MyRouterDelegate extends RouterDelegate<PageConfiguration>
with ChangeNotifier, PopNavigatorRouterDelegateMixin<PageConfiguration> {
final NavigationCubit _cubit;
MyRouterDelegate(this._cubit);
@override
Widget build(BuildContext context) {
return BlocConsumer<NavigationCubit, NavigationStack>(
builder: (context, stack) => Navigator(
// When the pages property is replaced with the code below everthing
// seems to work as expected.
pages: stack.pages,
//pages: const [MaterialPage(child: MainPageWidget())],
key: navigatorKey,
onPopPage: (route, result) => _onPopPage.call(route, result),
),
listener: (context, stack) {},
);
}
/// Handles the hardware back button (mainly present on android).
bool _onPopPage(Route<dynamic> route, dynamic result) {
final didPop = route.didPop(result);
if (!didPop) {
return false;
}
if (_cubit.canPop) {
_cubit.pop();
return true;
} else {
return false;
}
}
@override
Future<void> setNewRoutePath(PageConfiguration configuration) async {
if (configuration.route != "/") {
return _cubit.push(configuration);
}
}
/// This getter is called by the router when it detects it may have changed,
/// because of a rebuild.
///
/// This getter is necessary for backward and forward buttons to work as
/// expected.
@override
PageConfiguration? get currentConfiguration => _cubit.state.topmost;
// This key causes the issue.
@override
final GlobalKey<NavigatorState> navigatorKey = GlobalKey();
}
class NavigationStack {
final List<PageConfiguration> _stack;
bool get canPop {
return _stack.length > 1;
}
List<PageConfiguration> get configs => List.unmodifiable(_stack);
int get count => _stack.length;
@override
int get hashCode => Object.hashAll([_stack]);
List<MyPage> get pages => List.unmodifiable(_stack.map((e) => e.page));
PageConfiguration get topmost => _stack.last;
const NavigationStack(this._stack);
NavigationStack pop() {
if (canPop) {
_stack.remove(_stack.last);
}
return NavigationStack(_stack);
}
NavigationStack push(PageConfiguration configuration) {
if (_stack.last != configuration) {
_stack.add(configuration);
}
return NavigationStack(_stack);
}
@override
operator ==(other) =>
other is NavigationStack && listEquals(other._stack, _stack);
}
class PageConfiguration {
final String? name;
late final MyPage page;
late final String route;
late final Uri uri;
PageConfiguration({required this.uri, this.name}) {
route = uri.toString();
page = MyRouter.instance().getPage(this);
}
PageConfiguration.parse({
required String location,
this.name,
}) {
uri = location.isEmpty ? Uri.parse("/") : Uri.parse(location);
route = uri.toString();
page = MyRouter.instance().getPage(this);
}
@override
operator ==(other) =>
other is PageConfiguration &&
other.name == name &&
other.page == page &&
other.route == route &&
other.uri == uri;
@override
int get hashCode => Object.hash(name, page, route, uri);
}
/// Represents a function used to build the transition of a specific [MyPage].
typedef TransitionAnimationBuilder = Widget Function(
BuildContext,
Animation<double>,
Animation<double>,
Widget,
);
abstract class MyPage<T> extends Page<T> {
final TransitionAnimationBuilder? animationBuilder;
final Duration transitionDuration;
final Duration reverseTransitionDuration;
const MyPage({
this.animationBuilder,
super.arguments,
super.name,
super.key,
super.restorationId,
this.reverseTransitionDuration = const Duration(milliseconds: 400),
this.transitionDuration = const Duration(milliseconds: 400),
});
/// The content of the page is built by overriding this [build] function.
Widget build(BuildContext context);
@override
Route<T> createRoute(BuildContext context) {
return PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) =>
this.build(context),
reverseTransitionDuration: this.reverseTransitionDuration,
transitionsBuilder:
this.animationBuilder ?? this._defaultAnimationBuilder,
transitionDuration: this.transitionDuration,
);
}
/// Provides a default page transition.
Widget _defaultAnimationBuilder(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child) {
const begin = Offset(0.0, 1.0);
const end = Offset.zero;
const curve = Curves.elasticIn;
final tween = Tween(begin: begin, end: end).chain(CurveTween(curve: curve));
return SlideTransition(
position: animation.drive(tween),
child: child,
);
}
}
class MyRouter {
static final MyRouter _singleton = MyRouter._internal();
factory MyRouter.instance() {
return _singleton;
}
MyRouter._internal();
MyPage getPage(PageConfiguration pageConfiguration) {
switch (pageConfiguration.uri.toString()) {
case "/":
return MainPage();
default:
throw Exception("Unknown route.");
}
}
}
class NavigationCubit extends Cubit<NavigationStack> {
bool get canPop {
return state.canPop;
}
@override
int get hashCode => Object.hashAll([state]);
NavigationCubit(List<PageConfiguration> initialPages)
: super(NavigationStack(initialPages));
void pop() {
emit(state.pop());
}
void push(PageConfiguration configuration) {
emit(state.push(configuration));
}
@override
operator ==(other) => other is NavigationCubit && other.state == state;
}
For convenience, the code is also on DartPad.
The problem is that these two exceptions are thrown whenever I try to execute this code:
══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY
╞═══════════════════════════════════════════════════════════
The following assertion was thrown building BlocListener<NavigationCubit,
NavigationStack>(state:
_BlocListenerBaseState<NavigationCubit, NavigationStack>#7d9f3):
'package:flutter/src/widgets/navigator.dart': Failed assertion: line 2910 pos 17: '!pageBased ||
route.settings is Page': is not true.
Either the assertion indicates an error in the framework itself, or we should provide
substantially
more information in this error message to help you determine and fix the underlying cause.
In either case, please report this assertion by filing a bug on GitHub:
https://github.com/flutter/flutter/issues/new?template=2_bug.yml
The relevant error-causing widget was:
BlocListener<NavigationCubit, NavigationStack>
BlocListener:file:///Users/ferdinandschaffler/.pub-cache/hosted/pub.dev/flutter_bloc-8.1.3/lib
/src/bloc_builder.dart:162:12
When the exception was thrown, this was the stack:
#2 new _RouteEntry (package:flutter/src/widgets/navigator.dart:2910:17)
#3 NavigatorState.restoreState (package:flutter/src/widgets/navigator.dart:3587:33)
#4 RestorationMixin._doRestore (package:flutter/src/widgets/restoration.dart:924:5)
#5 RestorationMixin.didChangeDependencies
(package:flutter/src/widgets/restoration.dart:910:7)
#6 NavigatorState.didChangeDependencies
(package:flutter/src/widgets/navigator.dart:3656:11)
#7 StatefulElement._firstBuild (package:flutter/src/widgets/framework.dart:5620:11)
#8 ComponentElement.mount (package:flutter/src/widgets/framework.dart:5447:5)
... Normal element mounting (9 frames)
#17 SingleChildWidgetElementMixin.mount (package:nested/nested.dart:222:11)
... Normal element mounting (427 frames)
#444 _InheritedProviderScopeElement.mount
(package:provider/src/inherited_provider.dart:411:11)
... Normal element mounting (7 frames)
#451 SingleChildWidgetElementMixin.mount (package:nested/nested.dart:222:11)
... Normal element mounting (7 frames)
#458 SingleChildWidgetElementMixin.mount (package:nested/nested.dart:222:11)
... Normal element mounting (33 frames)
#491 Element.inflateWidget (package:flutter/src/widgets/framework.dart:4326:16)
#492 Element.updateChild (package:flutter/src/widgets/framework.dart:3837:18)
#493 _RawViewElement._updateChild (package:flutter/src/widgets/view.dart:289:16)
#494 _RawViewElement.mount (package:flutter/src/widgets/view.dart:312:5)
... Normal element mounting (7 frames)
#501 Element.inflateWidget (package:flutter/src/widgets/framework.dart:4326:16)
#502 Element.updateChild (package:flutter/src/widgets/framework.dart:3837:18)
#503 RootElement._rebuild (package:flutter/src/widgets/binding.dart:1334:16)
#504 RootElement.mount (package:flutter/src/widgets/binding.dart:1303:5)
#505 RootWidget.attach.<anonymous closure> (package:flutter/src/widgets/binding.dart:1256:18)
#506 BuildOwner.buildScope (package:flutter/src/widgets/framework.dart:2835:19)
#507 RootWidget.attach (package:flutter/src/widgets/binding.dart:1255:13)
#508 WidgetsBinding.attachToBuildOwner (package:flutter/src/widgets/binding.dart:1083:27)
#509 WidgetsBinding.attachRootWidget (package:flutter/src/widgets/binding.dart:1065:5)
#510 WidgetsBinding.scheduleAttachRootWidget.<anonymous closure>
(package:flutter/src/widgets/binding.dart:1051:7)
#514 _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:184:12)
(elided 5 frames from class _AssertionError, class _Timer, and dart:async-patch)
════════════════════════════════════════════════════════════════════════════════════════════════
════
Another exception was thrown: A GlobalKey was used multiple times inside one widget's child
list.
Both these exceptions are hard for me to retrace, because MyPage
actually is a subclass of Page
and the navigatorKey
should also only be used once, because the MyApp
Widget
is never rebuilt.
My question would therefore be if someone could please explain as to why these exceptions occur and how to fix them.
2
Answers
With the help of the answer above I was able to resolve the issue.
All that was needed was to add
settings: this
to thePageRouteBuilder
returned from thecreateRoute
method in theMyPage<T>
class like so:Here is the fixed Dartpad.
After some debugging, I now think there is a problem with the method
createRoute
in the classMyPage
.PageRouteBuilder
is used to create the route without providing the argumentsettings
.As a result,
PageRouteBuilder
creates a defaultRouteSettings
objects leading to the exception "route is not page based".The image below shows the debug view of the local variable
route.settings
:Using the workaround described below the variable
route.settings
becomes:and the widget tree looks like this:
Have a look at the
go_router
transition page, the link you provided in the comment section. They implementedcreateRoute
differently and pass on the argumentsetting
to the super constructor:The only workaround that I can propose is to refactor
MyPage
by making it a subclass ofMaterialPage
.and change
MainPage
:The simplified example is listed below. I omitted the customization of
MyPage
: