Search code examples
flutterdartblocflutter-blocflutter-exception

Router based navigation with flutter bloc


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.


Solution

  • 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.