Tabbed + TabLess navigation with Beamer: Multiple widgets used the same GlobalKey

189 Views Asked by At

I am trying to implement a navigational pattern where a few routes must be displayed in tabs, but the rest are to be displayed without tabs. I am definitely missing something.

The idea is to hide the bottom navigation when "tabless" routes are displayed. The approach seems to work unless the Android Back Button is used.

import 'package:beamer/beamer.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

final appRouterDelegate = BeamerDelegate(
  initialPath: '/tab1',
  locationBuilder: RoutesLocationBuilder(
    routes: {
      '*': (context, state, data) => AppPage(),
    },
  ),
);

final routerDelegate = BeamerDelegate(
  initialPath: appRouterDelegate.initialPath,
  //
  locationBuilder: (routeInformation, _) {
    if (routeInformation.location!.startsWith('/tabless')) {
      return TabLessLocation(routeInformation);
    }
    return TabbedLocation(routeInformation);
  },
);

class TabbedLocation extends BeamLocation<BeamState> {
  TabbedLocation(RouteInformation routeInformation) : super(routeInformation);

  @override
  List<BeamPage> buildPages(BuildContext context, BeamState state) {
    return [
      if (state.uri.path == '/tab1')
        BeamPage(
          key: ValueKey('/tab1'),
          child: Tab1Page(),
        ),
      if (state.uri.path == '/tab2')
        BeamPage(
          key: ValueKey('/tab2'),
          child: Tab2Page(),
        ),
    ];
  }

  @override
  List<Pattern> get pathPatterns => ['/tab1', '/tab2'];
}

class TabLessLocation extends BeamLocation<BeamState> {
  TabLessLocation(RouteInformation routeInformation) : super(routeInformation);

  @override
  List<BeamPage> buildPages(BuildContext context, BeamState state) {
    return [
      if (state.uri.path.startsWith('/tabless'))
        BeamPage(
          key: ValueKey('/tabless'),
          child: TabLessPage(),
        ),
      if (state.uri.path == '/tabless/subpage')
        BeamPage(
          key: ValueKey('/tabless/subpage'),
          child: TabLessSubPage(),
        ),
    ];
  }

  @override
  List<Pattern> get pathPatterns => ['/tabless', '/tabless/subpage'];
}

class TestPage extends StatelessWidget {
  TestPage({required this.title, required this.links});

  final String title;
  final List<String> links;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(title),
      ),
      body: Column(
        children: links
            .map(
              (link) => OutlinedButton(
                child: Text("Go To " + link),
                onPressed: () {
                  routerDelegate.beamToNamed(link);
                },
              ),
            )
            .toList(),
      ),
    );
  }
}

class Tab1Page extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return TestPage(title: 'Tab1', links: ['/tab2', '/tabless', '/tabless/subpage', ]);
  }
}

class Tab2Page extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return TestPage(title: 'Tab2', links: ['/tab1', '/tabless', '/tabless/subpage']);
  }
}

class TabLessPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return TestPage(title: 'Tabless', links: ['/tab1', '/tab2', '/tabless/subpage']);
  }
}

class TabLessSubPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return TestPage(title: 'TabLessSubpage', links: ['/tab1', '/tab2', '/tabless']);
  }
}

class AppPage extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => _AppPageState();
}

class _AppPageState extends State<AppPage> {
  int _currentIndex = 0;
  bool _showTabs = true;

  @override
  void initState() {
    routerDelegate.addListener(() {
      print("Route changed");
      print("appRouterDelegate location: ${appRouterDelegate.currentBeamLocation.state.routeInformation.location!}" ?? '');
      String? location = routerDelegate.currentBeamLocation.state.routeInformation.location;
      print("routerDelegate location: ${location!}" ?? '');

      if (location == '/tab1') {
        return setState(() {
          _showTabs = true;
          _currentIndex = 0;
        });
      }
      if (location == '/tab2') {
        return setState(() {
          _showTabs = true;
          _currentIndex = 1;
        });
      }
      setState(() {
        _showTabs = false;
      });

    });
    super.initState();
  }

  @override
  void didUpdateWidget(covariant AppPage oldWidget) {
    super.didUpdateWidget(oldWidget);
  }

  @override
  Widget build(BuildContext context) {
    return SafeArea(
      child: Scaffold(
        body: Beamer(
          routerDelegate: routerDelegate,
          backButtonDispatcher: BeamerBackButtonDispatcher(
            delegate: routerDelegate,
            fallbackToBeamBack: false,
          ),
        ),
        bottomNavigationBar: _showTabs
            ? BottomNavigationBar(
                currentIndex: _currentIndex,
                items: const [
                  BottomNavigationBarItem(icon: Icon(Icons.tab), label: 'Tab1'),
                  BottomNavigationBarItem(icon: Icon(Icons.tab), label: 'Tab2'),
                ],
                onTap: (index) {
                  // if (index == _currentIndex) return;
                  if (index == 0) {
                    routerDelegate.beamToNamed('/tab1');
                  }
                  if (index == 1) {
                    routerDelegate.beamToNamed('/tab2');
                  }
                },
              )
            : null,
      ),
    );
  }
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'Tabs + TabLess',
      routeInformationParser: BeamerParser(),
      routerDelegate: appRouterDelegate,
      backButtonDispatcher: BeamerBackButtonDispatcher(
        delegate: appRouterDelegate,
        fallbackToBeamBack: false,
      ),
    );
  }
}

As a result, I receive an error:

E/flutter (11913): [ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled Exception: 'package:flutter/src/widgets/focus_manager.dart': Failed assertion: line 1252 pos 12: '_focusedChildren.isEmpty || _focusedChildren.last.enclosingScope == this': Focused child does not have the same idea of its enclosing scope as the scope does.
E/flutter (11913): #0      _AssertionError._doThrowNew (dart:core-patch/errors_patch.dart:51:61)
E/flutter (11913): #1      _AssertionError._throwNew (dart:core-patch/errors_patch.dart:40:5)
E/flutter (11913): #2      FocusScopeNode.focusedChild (package:flutter/src/widgets/focus_manager.dart:1252:12)
E/flutter (11913): #3      _Autofocus.applyIfValid (package:flutter/src/widgets/focus_manager.dart:139:37)
E/flutter (11913): #4      FocusManager._applyFocusChange (package:flutter/src/widgets/focus_manager.dart:1610:17)
E/flutter (11913): #5      _microtaskLoop (dart:async/schedule_microtask.dart:40:21)
E/flutter (11913): #6      _startMicrotaskLoop (dart:async/schedule_microtask.dart:49:5)
E/flutter (11913): 

======== Exception caught by widgets library =======================================================
The following assertion was thrown while finalizing the widget tree:
Multiple widgets used the same GlobalKey.

The key [LabeledGlobalKey<NavigatorState>#0d783] was used by multiple widgets. The parents of those widgets were different widgets that both had the following description:
  Builder
A GlobalKey can only be specified on one widget at a time in the widget tree.
When the exception was thrown, this was the stack: 
#0      BuildOwner._debugVerifyGlobalKeyReservation.<anonymous closure>.<anonymous closure>.<anonymous closure> (package:flutter/src/widgets/framework.dart:2988:13)
#1      _LinkedHashMapMixin.forEach (dart:collection-patch/compact_hash.dart:625:13)
#2      BuildOwner._debugVerifyGlobalKeyReservation.<anonymous closure>.<anonymous closure> (package:flutter/src/widgets/framework.dart:2932:20)
#3      _LinkedHashMapMixin.forEach (dart:collection-patch/compact_hash.dart:625:13)
#4      BuildOwner._debugVerifyGlobalKeyReservation.<anonymous closure> (package:flutter/src/widgets/framework.dart:2927:36)
#5      BuildOwner._debugVerifyGlobalKeyReservation (package:flutter/src/widgets/framework.dart:2996:6)
#6      BuildOwner.finalizeTree.<anonymous closure> (package:flutter/src/widgets/framework.dart:3053:11)
#7      BuildOwner.finalizeTree (package:flutter/src/widgets/framework.dart:3135:8)
#8      WidgetsBinding.drawFrame (package:flutter/src/widgets/binding.dart:906:19)
#9      RendererBinding._handlePersistentFrameCallback (package:flutter/src/rendering/binding.dart:358:5)
#10     SchedulerBinding._invokeFrameCallback (package:flutter/src/scheduler/binding.dart:1284:15)
#11     SchedulerBinding.handleDrawFrame (package:flutter/src/scheduler/binding.dart:1214:9)
#12     SchedulerBinding._handleDrawFrame (package:flutter/src/scheduler/binding.dart:1072:5)
#13     _invoke (dart:ui/hooks.dart:142:13)
#14     PlatformDispatcher._drawFrame (dart:ui/platform_dispatcher.dart:359:5)
#15     _drawFrame (dart:ui/hooks.dart:112:31)
====================================================================================================

======== Exception caught by scheduler library =====================================================
The following assertion was thrown during a scheduler callback:
Focused child does not have the same idea of its enclosing scope as the scope does.
'package:flutter/src/widgets/focus_manager.dart':
Failed assertion: line 1252 pos 12: '_focusedChildren.isEmpty || _focusedChildren.last.enclosingScope == this'


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

When the exception was thrown, this was the stack: 
#2      FocusScopeNode.focusedChild (package:flutter/src/widgets/focus_manager.dart:1252:12)
#3      FocusScopeNode._doRequestFocus (package:flutter/src/widgets/focus_manager.dart:1337:17)
#4      FocusScopeNode.setFirstFocus (package:flutter/src/widgets/focus_manager.dart:1304:13)
#5      _ModalScopeState._routeSetState (package:flutter/src/widgets/routes.dart:886:57)
#6      ModalRoute.setState (package:flutter/src/widgets/routes.dart:1043:31)
#7      ModalRoute.offstage= (package:flutter/src/widgets/routes.dart:1458:5)
#8      HeroController._startHeroTransition (package:flutter/src/widgets/heroes.dart:899:8)
#9      HeroController._maybeStartHeroTransition.<anonymous closure> (package:flutter/src/widgets/heroes.dart:883:11)
#10     SchedulerBinding._invokeFrameCallback (package:flutter/src/scheduler/binding.dart:1284:15)
#11     SchedulerBinding.handleDrawFrame (package:flutter/src/scheduler/binding.dart:1223:9)
#12     SchedulerBinding._handleDrawFrame (package:flutter/src/scheduler/binding.dart:1072:5)
#13     _invoke (dart:ui/hooks.dart:142:13)
#14     PlatformDispatcher._drawFrame (dart:ui/platform_dispatcher.dart:359:5)
#15     _drawFrame (dart:ui/hooks.dart:112:31)
(elided 2 frames from class _AssertionError)
====================================================================================================

pubspec.yaml:

name: navig
description: A new Flutter project.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 1.0.0+1

environment:
  sdk: '>=3.0.6 <4.0.0'

dependencies:
  flutter:
    sdk: flutter

  cupertino_icons: ^1.0.2
  beamer: ^1.5.6

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^2.0.0

flutter:
  uses-material-design: true

Here is the gist: https://gist.github.com/andr2k/27918aec82296f45c9d8ac41450b559a

Visual demonstration

1

There are 1 best solutions below

0
andr2k On

Apparently, the issue arises when one Beamer is under another Beamer. As a temporary workaround, switched to one Beamer with a parametrized BackButtonDispatcher instead of using multiple Beamers:

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'Tabs + TabLess',
      routeInformationParser: BeamerParser(),
      routerDelegate: routerDelegate,
      backButtonDispatcher: BeamerBackButtonDispatcher(
          delegate: routerDelegate,
          fallbackToBeamBack: false,
          onBack: (BeamerDelegate delegate) async {
            if (await delegate.popRoute()) {
              return true;
            }

            if (delegate.currentBeamLocation is TabbedLocation) {
              return false;
            }

            if (delegate.canPopBeamLocation) {
              delegate.popBeamLocation();
              return true;
            }

            return false;
          }),
    );
  }
}