skip to Main Content

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


  1. Chosen as BEST ANSWER

    With the help of the answer above I was able to resolve the issue.

    All that was needed was to add settings: this to the PageRouteBuilder returned from the createRoute method in the MyPage<T> class like so:

    @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,
        settings: this
      );
    }
    

    Here is the fixed Dartpad.


  2. After some debugging, I now think there is a problem with the method createRoute in the class MyPage. PageRouteBuilder is used to create the route without providing the argument settings.

    As a result, PageRouteBuilder creates a default RouteSettings objects leading to the exception "route is not page based".
    The image below shows the debug view of the local variable route.settings:

    enter image description here

    Using the workaround described below the variable route.settings becomes:

    enter image description here

    and the widget tree looks like this:

    enter image description here

    Have a look at the go_router transition page, the link you provided in the comment section. They implemented createRoute differently and pass on the argument setting to the super constructor:

    class _CustomTransitionPageRoute<T> extends PageRoute<T> {
      _CustomTransitionPageRoute(CustomTransitionPage<T> page)
          : super(settings: page);
    

    The only workaround that I can propose is to refactor MyPage by making it a subclass of MaterialPage.

    class MyPage<T> extends MaterialPage<T> {
      const MyPage({required super.child});
    }
    

    and change MainPage:

    class MainPage extends MyPage {
      const MainPage():super(child: const MainPageWidget());
    }
    

    The simplified example is listed below. I omitted the customization of MyPage:

    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: "Memes",
            theme: ThemeData(
              colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
              useMaterial3: true,
            ),
            routeInformationParser: MyRouteInformationParser(),
            routerDelegate: MyRouterDelegate(_navigationCubit),
          ),
        );
      }
    }
    
    class MainPage extends MyPage {
      const MainPage():super(child: 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 {
        return 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,
    );
    
    class MyPage<T> extends MaterialPage<T> {
      const MyPage({required super.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 const 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;
    }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search