From ed76d865271bd3d1124f8eb1fa6d812141a34597 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Lo=CC=88fstrand?= Date: Wed, 4 Sep 2024 23:01:08 +0200 Subject: [PATCH 1/7] Updated custom_stateful_shell_route.dart example to better support swiping in TabView. Also added code to demonstrate use of PageView instead of TabView. --- .../others/custom_stateful_shell_route.dart | 118 +++++++++++++++++- 1 file changed, 114 insertions(+), 4 deletions(-) diff --git a/packages/go_router/example/lib/others/custom_stateful_shell_route.dart b/packages/go_router/example/lib/others/custom_stateful_shell_route.dart index 5af1504234ac..504eaf96de1b 100644 --- a/packages/go_router/example/lib/others/custom_stateful_shell_route.dart +++ b/packages/go_router/example/lib/others/custom_stateful_shell_route.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; + import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -78,13 +80,13 @@ class NestedTabNavigationExampleApp extends StatelessWidget { ], ), - // The route branch for the third tab of the bottom navigation bar. + // The route branch for the second tab of the bottom navigation bar. StatefulShellBranch( // StatefulShellBranch will automatically use the first descendant // GoRoute as the initial location of the branch. If another route // is desired, specify the location of it using the defaultLocation // parameter. - // defaultLocation: '/c2', + // defaultLocation: '/b2', routes: [ StatefulShellRoute( builder: (BuildContext context, GoRouterState state, @@ -271,7 +273,7 @@ class RootScreenA extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('Root of section A'), + title: const Text('Section A root'), ), body: Center( child: Column( @@ -387,6 +389,10 @@ class TabbedRootScreen extends StatefulWidget { @override State createState() => _TabbedRootScreenState(); + + /// To use an alternative implementation using a PageView, replace the line + /// above with the one below: + //State createState() => _TabbedRootScreenStatePageView(); } class _TabbedRootScreenState extends State @@ -396,6 +402,25 @@ class _TabbedRootScreenState extends State vsync: this, initialIndex: widget.navigationShell.currentIndex); + void _switchedTab() { + if (_tabController.index != widget.navigationShell.currentIndex) { + widget.navigationShell.goBranch(_tabController.index); + } + } + + @override + void initState() { + super.initState(); + _tabController.addListener(_switchedTab); + } + + @override + void dispose() { + _tabController.removeListener(_switchedTab); + _tabController.dispose(); + super.dispose(); + } + @override void didUpdateWidget(covariant TabbedRootScreen oldWidget) { super.didUpdateWidget(oldWidget); @@ -410,7 +435,8 @@ class _TabbedRootScreenState extends State return Scaffold( appBar: AppBar( - title: const Text('Root of Section B (nested TabBar shell)'), + title: Text( + 'Section B root (tab: ${widget.navigationShell.currentIndex + 1})'), bottom: TabBar( controller: _tabController, tabs: tabs, @@ -428,6 +454,90 @@ class _TabbedRootScreenState extends State } } +/// Alternative implementation _TabbedRootScreenState, demonstrating the use of +/// a PageView. +// ignore: unused_element +class _TabbedRootScreenStatePageView extends State + with SingleTickerProviderStateMixin { + late final PageController _pageController = PageController( + initialPage: widget.navigationShell.currentIndex, + ); + Timer? _throttle; + + void _scrolledPageView() { + // Simple throttling implementation to handle scroll events. + int nextPage() => (_pageController.page ?? 0).round(); + if (nextPage() != widget.navigationShell.currentIndex) { + _throttle?.cancel(); + _throttle = Timer(const Duration(milliseconds: 100), () { + widget.navigationShell.goBranch(nextPage()); + _throttle = null; + }); + } + } + + @override + void initState() { + super.initState(); + _pageController.addListener(_scrolledPageView); + } + + @override + void dispose() { + _pageController.removeListener(_scrolledPageView); + _pageController.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant TabbedRootScreen oldWidget) { + super.didUpdateWidget(oldWidget); + _pageController.jumpToPage(widget.navigationShell.currentIndex); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text( + 'Section B root (tab ${widget.navigationShell.currentIndex + 1})'), + ), + body: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton( + onPressed: () => _onTabTap(0), + child: const Text('Tab 1'), + ), + ElevatedButton( + onPressed: () => _onTabTap(1), + child: const Text('Tab 2'), + ), + ]), + Expanded( + child: PageView( + controller: _pageController, + children: widget.children, + ), + ), + ], + ), + ); + } + + void _onTabTap(int index) { + if (_pageController.hasClients) { + _pageController.animateToPage( + index, + duration: const Duration(milliseconds: 500), + curve: Curves.bounceOut, + ); + } + } +} + /// Widget for the pages in the top tab bar. class TabScreen extends StatelessWidget { /// Creates a RootScreen From 485766b53bfb4bc85d30502347e0cb1a0ecffaad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Lo=CC=88fstrand?= Date: Thu, 5 Sep 2024 08:11:49 +0200 Subject: [PATCH 2/7] Updated version and CHANGELOG. --- packages/go_router/CHANGELOG.md | 4 ++++ packages/go_router/pubspec.yaml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index 137f8d2329d4..ef70f93afed4 100644 --- a/packages/go_router/CHANGELOG.md +++ b/packages/go_router/CHANGELOG.md @@ -1,3 +1,7 @@ +## 14.2.8 + +- Updated custom_stateful_shell_route example to better support swiping in TabView as well as demonstration of the use of PageView. + ## 14.2.7 - Fixes issue so that the parseRouteInformationWithContext can handle non-http Uris. diff --git a/packages/go_router/pubspec.yaml b/packages/go_router/pubspec.yaml index 6499a9cf4f26..7c494f15aea1 100644 --- a/packages/go_router/pubspec.yaml +++ b/packages/go_router/pubspec.yaml @@ -1,7 +1,7 @@ name: go_router description: A declarative router for Flutter based on Navigation 2 supporting deep linking, data-driven routes and more -version: 14.2.7 +version: 14.2.8 repository: https://github.com/flutter/packages/tree/main/packages/go_router issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+go_router%22 From b20756a71e0fa347b650a15fc958ee9f6cb78289 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Lo=CC=88fstrand?= Date: Thu, 5 Sep 2024 22:32:52 +0200 Subject: [PATCH 3/7] Added test case for custom_stateful_shell_route example, to test that swipe of TabView (updating tab index of TabController) correctly navigates to the appropriate destination. --- .../others/custom_stateful_shell_route.dart | 35 ++++++++++++------- .../custom_stateful_shell_route_test.dart | 30 ++++++++++++++++ 2 files changed, 53 insertions(+), 12 deletions(-) create mode 100644 packages/go_router/example/test/custom_stateful_shell_route_test.dart diff --git a/packages/go_router/example/lib/others/custom_stateful_shell_route.dart b/packages/go_router/example/lib/others/custom_stateful_shell_route.dart index 504eaf96de1b..e33a27ed4b5f 100644 --- a/packages/go_router/example/lib/others/custom_stateful_shell_route.dart +++ b/packages/go_router/example/lib/others/custom_stateful_shell_route.dart @@ -12,6 +12,10 @@ final GlobalKey _rootNavigatorKey = GlobalKey(debugLabel: 'root'); final GlobalKey _tabANavigatorKey = GlobalKey(debugLabel: 'tabANav'); +@visibleForTesting +// ignore: public_member_api_docs +final GlobalKey tabbedRootScreenKey = + GlobalKey(debugLabel: 'TabbedRootScreen'); // This example demonstrates how to setup nested navigation using a // BottomNavigationBar, where each bar item uses its own persistent navigator, @@ -104,7 +108,10 @@ class NestedTabNavigationExampleApp extends StatelessWidget { // See TabbedRootScreen for more details on how the children // are managed (in a TabBarView). return TabbedRootScreen( - navigationShell: navigationShell, children: children); + navigationShell: navigationShell, + key: tabbedRootScreenKey, + children: children, + ); }, // This bottom tab uses a nested shell, wrapping sub routes in a // top TabBar. @@ -388,43 +395,47 @@ class TabbedRootScreen extends StatefulWidget { final List children; @override - State createState() => _TabbedRootScreenState(); + State createState() => TabbedRootScreenState(); /// To use an alternative implementation using a PageView, replace the line /// above with the one below: //State createState() => _TabbedRootScreenStatePageView(); } -class _TabbedRootScreenState extends State +@visibleForTesting +// ignore: public_member_api_docs +class TabbedRootScreenState extends State with SingleTickerProviderStateMixin { - late final TabController _tabController = TabController( + @visibleForTesting + // ignore: public_member_api_docs + late final TabController tabController = TabController( length: widget.children.length, vsync: this, initialIndex: widget.navigationShell.currentIndex); void _switchedTab() { - if (_tabController.index != widget.navigationShell.currentIndex) { - widget.navigationShell.goBranch(_tabController.index); + if (tabController.index != widget.navigationShell.currentIndex) { + widget.navigationShell.goBranch(tabController.index); } } @override void initState() { super.initState(); - _tabController.addListener(_switchedTab); + tabController.addListener(_switchedTab); } @override void dispose() { - _tabController.removeListener(_switchedTab); - _tabController.dispose(); + tabController.removeListener(_switchedTab); + tabController.dispose(); super.dispose(); } @override void didUpdateWidget(covariant TabbedRootScreen oldWidget) { super.didUpdateWidget(oldWidget); - _tabController.index = widget.navigationShell.currentIndex; + tabController.index = widget.navigationShell.currentIndex; } @override @@ -438,12 +449,12 @@ class _TabbedRootScreenState extends State title: Text( 'Section B root (tab: ${widget.navigationShell.currentIndex + 1})'), bottom: TabBar( - controller: _tabController, + controller: tabController, tabs: tabs, onTap: (int tappedIndex) => _onTabTap(context, tappedIndex), )), body: TabBarView( - controller: _tabController, + controller: tabController, children: widget.children, ), ); diff --git a/packages/go_router/example/test/custom_stateful_shell_route_test.dart b/packages/go_router/example/test/custom_stateful_shell_route_test.dart new file mode 100644 index 000000000000..2ad5658e65e7 --- /dev/null +++ b/packages/go_router/example/test/custom_stateful_shell_route_test.dart @@ -0,0 +1,30 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router_examples/others/custom_stateful_shell_route.dart'; + +void main() { + testWidgets( + 'Changing active tab in TabController (in TabbedRootScreen, ' + 'Section B) correctly navigates to appropriate screen', + (WidgetTester tester) async { + await tester.pumpWidget(NestedTabNavigationExampleApp()); + expect(find.text('Screen A'), findsOneWidget); + + // navigate to ScreenB + await tester.tap(find.text('Section B')); + await tester.pumpAndSettle(); + expect(find.text('Screen B1'), findsOneWidget); + + final TabController? tabController = + tabbedRootScreenKey.currentState?.tabController; + expect(tabController, isNotNull); + + tabbedRootScreenKey.currentState?.tabController.index = 1; + await tester.pumpAndSettle(); + expect(find.text('Screen B2'), findsOneWidget); + }); +} From fe63aee4fad3e688faf78f13f97a0931760c940f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Lo=CC=88fstrand?= Date: Thu, 5 Sep 2024 22:38:38 +0200 Subject: [PATCH 4/7] Added some comments --- .../example/test/custom_stateful_shell_route_test.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/go_router/example/test/custom_stateful_shell_route_test.dart b/packages/go_router/example/test/custom_stateful_shell_route_test.dart index 2ad5658e65e7..4a001a9dc562 100644 --- a/packages/go_router/example/test/custom_stateful_shell_route_test.dart +++ b/packages/go_router/example/test/custom_stateful_shell_route_test.dart @@ -8,8 +8,8 @@ import 'package:go_router_examples/others/custom_stateful_shell_route.dart'; void main() { testWidgets( - 'Changing active tab in TabController (in TabbedRootScreen, ' - 'Section B) correctly navigates to appropriate screen', + 'Changing active tab in TabController of TabbedRootScreen (root screen ' + 'of branch/section B) correctly navigates to appropriate screen', (WidgetTester tester) async { await tester.pumpWidget(NestedTabNavigationExampleApp()); expect(find.text('Screen A'), findsOneWidget); @@ -19,10 +19,12 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Screen B1'), findsOneWidget); + // Get TabController from TabbedRootScreen (root screen of branch/section B) final TabController? tabController = tabbedRootScreenKey.currentState?.tabController; expect(tabController, isNotNull); + // Simulate swiping TabView to change active tab in TabController tabbedRootScreenKey.currentState?.tabController.index = 1; await tester.pumpAndSettle(); expect(find.text('Screen B2'), findsOneWidget); From 959165c2e5258767af8596c4ef5a6365ca0ffd47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Lo=CC=88fstrand?= Date: Mon, 9 Sep 2024 15:06:11 +0200 Subject: [PATCH 5/7] Added code to showcase the somewhat related use case with using a CupertinoTabScaffold. Small rename. --- .../others/custom_stateful_shell_route.dart | 72 ++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/packages/go_router/example/lib/others/custom_stateful_shell_route.dart b/packages/go_router/example/lib/others/custom_stateful_shell_route.dart index e33a27ed4b5f..497927297088 100644 --- a/packages/go_router/example/lib/others/custom_stateful_shell_route.dart +++ b/packages/go_router/example/lib/others/custom_stateful_shell_route.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'package:collection/collection.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -58,6 +59,9 @@ class NestedTabNavigationExampleApp extends StatelessWidget { // are managed (using AnimatedBranchContainer). return ScaffoldWithNavBar( navigationShell: navigationShell, children: children); + // To use a Cupertino version of ScaffoldWithNavBar, use this instead: + // return CupertinoScaffoldWithNavBar( + // navigationShell: navigationShell, children: children); }, branches: [ // The route branch for the first tab of the bottom navigation bar. @@ -231,6 +235,70 @@ class ScaffoldWithNavBar extends StatelessWidget { } } +/// Alternative version of [ScaffoldWithNavBar], using a [CupertinoTabScaffold]. +// ignore: unused_element, unreachable_from_main +class CupertinoScaffoldWithNavBar extends StatefulWidget { + /// Constructs an [ScaffoldWithNavBar]. + // ignore: unreachable_from_main + const CupertinoScaffoldWithNavBar({ + required this.navigationShell, + required this.children, + Key? key, + }) : super(key: key ?? const ValueKey('ScaffoldWithNavBar')); + + /// The navigation shell and container for the branch Navigators. + // ignore: unreachable_from_main + final StatefulNavigationShell navigationShell; + + /// The children (branch Navigators) to display in a custom container + /// ([AnimatedBranchContainer]). + // ignore: unreachable_from_main + final List children; + + @override + State createState() => _CupertinoScaffoldWithNavBarState(); +} + +class _CupertinoScaffoldWithNavBarState + extends State { + late final CupertinoTabController tabController = + CupertinoTabController(initialIndex: widget.navigationShell.currentIndex); + + @override + void dispose() { + tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return CupertinoTabScaffold( + controller: tabController, + tabBar: CupertinoTabBar( + items: const [ + BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Section A'), + BottomNavigationBarItem(icon: Icon(Icons.work), label: 'Section B'), + ], + currentIndex: widget.navigationShell.currentIndex, + onTap: (int index) => _onTap(context, index), + ), + // Note: It is common to use CupertinoTabView for the tabBuilder when + // using CupertinoTabScaffold and CupertinoTabBar. This would however be + // redundant when using StatefulShellRoute, since a separate Navigator is + // already created for each branch, meaning we can simply use the branch + // Navigator Widgets (i.e. widget.children) directly. + tabBuilder: (BuildContext context, int index) => widget.children[index], + ); + } + + void _onTap(BuildContext context, int index) { + widget.navigationShell.goBranch( + index, + initialLocation: index == widget.navigationShell.currentIndex, + ); + } +} + /// Custom branch Navigator container that provides animated transitions /// when switching branches. class AnimatedBranchContainer extends StatelessWidget { @@ -399,7 +467,7 @@ class TabbedRootScreen extends StatefulWidget { /// To use an alternative implementation using a PageView, replace the line /// above with the one below: - //State createState() => _TabbedRootScreenStatePageView(); + //State createState() => _PageViewTabbedRootScreenState(); } @visibleForTesting @@ -468,7 +536,7 @@ class TabbedRootScreenState extends State /// Alternative implementation _TabbedRootScreenState, demonstrating the use of /// a PageView. // ignore: unused_element -class _TabbedRootScreenStatePageView extends State +class _PageViewTabbedRootScreenState extends State with SingleTickerProviderStateMixin { late final PageController _pageController = PageController( initialPage: widget.navigationShell.currentIndex, From 0e67efd9f86b94d04b553b377b369d4d78494ee6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Lo=CC=88fstrand?= Date: Tue, 10 Sep 2024 13:50:32 +0200 Subject: [PATCH 6/7] Some cleanup/clarification --- .../others/custom_stateful_shell_route.dart | 69 ++++++++----------- 1 file changed, 30 insertions(+), 39 deletions(-) diff --git a/packages/go_router/example/lib/others/custom_stateful_shell_route.dart b/packages/go_router/example/lib/others/custom_stateful_shell_route.dart index 497927297088..f2b80cf8eca1 100644 --- a/packages/go_router/example/lib/others/custom_stateful_shell_route.dart +++ b/packages/go_router/example/lib/others/custom_stateful_shell_route.dart @@ -59,9 +59,8 @@ class NestedTabNavigationExampleApp extends StatelessWidget { // are managed (using AnimatedBranchContainer). return ScaffoldWithNavBar( navigationShell: navigationShell, children: children); - // To use a Cupertino version of ScaffoldWithNavBar, use this instead: - // return CupertinoScaffoldWithNavBar( - // navigationShell: navigationShell, children: children); + // NOTE: To use a Cupertino version of ScaffoldWithNavBar, replace + // ScaffoldWithNavBar above with CupertinoScaffoldWithNavBar. }, branches: [ // The route branch for the first tab of the bottom navigation bar. @@ -116,6 +115,8 @@ class NestedTabNavigationExampleApp extends StatelessWidget { key: tabbedRootScreenKey, children: children, ); + // NOTE: To use a PageView version of TabbedRootScreen, + // replace TabbedRootScreen above with PagedRootScreen. }, // This bottom tab uses a nested shell, wrapping sub routes in a // top TabBar. @@ -464,10 +465,6 @@ class TabbedRootScreen extends StatefulWidget { @override State createState() => TabbedRootScreenState(); - - /// To use an alternative implementation using a PageView, replace the line - /// above with the one below: - //State createState() => _PageViewTabbedRootScreenState(); } @visibleForTesting @@ -533,47 +530,40 @@ class TabbedRootScreenState extends State } } +/// Alternative implementation of TabbedRootScreen, demonstrating the use of +/// a [PageView]. +// ignore: unreachable_from_main +class PagedRootScreen extends StatefulWidget { + /// Constructs a PagedRootScreen + // ignore: unreachable_from_main + const PagedRootScreen( + {required this.navigationShell, required this.children, super.key}); + + /// The current state of the parent StatefulShellRoute. + // ignore: unreachable_from_main + final StatefulNavigationShell navigationShell; + + /// The children (branch Navigators) to display in the [TabBarView]. + // ignore: unreachable_from_main + final List children; + + @override + State createState() => _PagedRootScreenState(); +} + /// Alternative implementation _TabbedRootScreenState, demonstrating the use of /// a PageView. -// ignore: unused_element -class _PageViewTabbedRootScreenState extends State - with SingleTickerProviderStateMixin { +class _PagedRootScreenState extends State { late final PageController _pageController = PageController( initialPage: widget.navigationShell.currentIndex, ); - Timer? _throttle; - - void _scrolledPageView() { - // Simple throttling implementation to handle scroll events. - int nextPage() => (_pageController.page ?? 0).round(); - if (nextPage() != widget.navigationShell.currentIndex) { - _throttle?.cancel(); - _throttle = Timer(const Duration(milliseconds: 100), () { - widget.navigationShell.goBranch(nextPage()); - _throttle = null; - }); - } - } - - @override - void initState() { - super.initState(); - _pageController.addListener(_scrolledPageView); - } @override void dispose() { - _pageController.removeListener(_scrolledPageView); _pageController.dispose(); super.dispose(); } - @override - void didUpdateWidget(covariant TabbedRootScreen oldWidget) { - super.didUpdateWidget(oldWidget); - _pageController.jumpToPage(widget.navigationShell.currentIndex); - } - @override Widget build(BuildContext context) { return Scaffold( @@ -587,16 +577,17 @@ class _PageViewTabbedRootScreenState extends State mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ ElevatedButton( - onPressed: () => _onTabTap(0), + onPressed: () => _animateToPage(0), child: const Text('Tab 1'), ), ElevatedButton( - onPressed: () => _onTabTap(1), + onPressed: () => _animateToPage(1), child: const Text('Tab 2'), ), ]), Expanded( child: PageView( + onPageChanged: (int i) => widget.navigationShell.goBranch(i), controller: _pageController, children: widget.children, ), @@ -606,7 +597,7 @@ class _PageViewTabbedRootScreenState extends State ); } - void _onTabTap(int index) { + void _animateToPage(int index) { if (_pageController.hasClients) { _pageController.animateToPage( index, From b573b35f4acc4bb90faece1be5c8bdd6331b5fc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Lo=CC=88fstrand?= Date: Tue, 10 Sep 2024 13:55:34 +0200 Subject: [PATCH 7/7] Additional cleanup --- .../example/lib/others/custom_stateful_shell_route.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/go_router/example/lib/others/custom_stateful_shell_route.dart b/packages/go_router/example/lib/others/custom_stateful_shell_route.dart index f2b80cf8eca1..49c040cf2a63 100644 --- a/packages/go_router/example/lib/others/custom_stateful_shell_route.dart +++ b/packages/go_router/example/lib/others/custom_stateful_shell_route.dart @@ -2,8 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:async'; - import 'package:collection/collection.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart';