[go_router_builder] Add go_router StatefulShellRoute support to go_router_builder (#4238)

fixes: https://github.com/flutter/flutter/issues/127371
This commit is contained in:
hangyu
2023-08-02 15:57:32 -07:00
committed by GitHub
parent 11b79b5fbe
commit c3a5fb9c7c
10 changed files with 608 additions and 73 deletions

View File

@ -1,3 +1,7 @@
## 2.3.0
* Adds Support for StatefulShellRoute
## 2.2.5
* Fixes a bug where shell routes without const constructor were not generated correctly.

View File

@ -8,12 +8,12 @@ To use `go_router_builder`, you need to have the following dependencies in
```yaml
dependencies:
# ...along with your other dependencies
go_router: ^7.0.0
go_router: ^9.0.3
dev_dependencies:
# ...along with your other dev-dependencies
build_runner: ^2.0.0
go_router_builder: ^2.0.0
go_router_builder: ^2.3.0
```
### Source code

View File

@ -26,8 +26,8 @@ RouteBase get $myShellRouteData => ShellRouteData.$route(
routes: [
GoRouteData.$route(
path: ':id',
factory: $UserRouteDataExtension._fromState,
parentNavigatorKey: UserRouteData.$parentNavigatorKey,
factory: $UserRouteDataExtension._fromState,
),
],
),

View File

@ -0,0 +1,266 @@
// 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.
// ignore_for_file: public_member_api_docs
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
part 'stateful_shell_route_example.g.dart';
final GlobalKey<NavigatorState> _sectionANavigatorKey =
GlobalKey<NavigatorState>(debugLabel: 'sectionANav');
void main() => runApp(App());
class App extends StatelessWidget {
App({super.key});
@override
Widget build(BuildContext context) => MaterialApp.router(
routerConfig: _router,
);
final GoRouter _router = GoRouter(
routes: $appRoutes,
initialLocation: '/detailsA',
);
}
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('foo')),
);
}
@TypedStatefulShellRoute<MyShellRouteData>(
branches: <TypedStatefulShellBranch<StatefulShellBranchData>>[
TypedStatefulShellBranch<BranchAData>(
routes: <TypedRoute<RouteData>>[
TypedGoRoute<DetailsARouteData>(path: '/detailsA'),
],
),
TypedStatefulShellBranch<BranchBData>(
routes: <TypedRoute<RouteData>>[
TypedGoRoute<DetailsBRouteData>(path: '/detailsB'),
],
),
],
)
class MyShellRouteData extends StatefulShellRouteData {
const MyShellRouteData();
@override
Widget builder(
BuildContext context,
GoRouterState state,
StatefulNavigationShell navigationShell,
) {
return navigationShell;
}
static const String $restorationScopeId = 'restorationScopeId';
static Widget $navigatorContainerBuilder(BuildContext context,
StatefulNavigationShell navigationShell, List<Widget> children) {
return ScaffoldWithNavBar(
navigationShell: navigationShell,
children: children,
);
}
}
class BranchAData extends StatefulShellBranchData {
const BranchAData();
}
class BranchBData extends StatefulShellBranchData {
const BranchBData();
static final GlobalKey<NavigatorState> $navigatorKey = _sectionANavigatorKey;
static const String $restorationScopeId = 'restorationScopeId';
}
class DetailsARouteData extends GoRouteData {
const DetailsARouteData();
@override
Widget build(BuildContext context, GoRouterState state) {
return const DetailsScreen(label: 'A');
}
}
class DetailsBRouteData extends GoRouteData {
const DetailsBRouteData();
@override
Widget build(BuildContext context, GoRouterState state) {
return const DetailsScreen(label: 'B');
}
}
/// Builds the "shell" for the app by building a Scaffold with a
/// BottomNavigationBar, where [child] is placed in the body of the Scaffold.
class ScaffoldWithNavBar extends StatelessWidget {
/// Constructs an [ScaffoldWithNavBar].
const ScaffoldWithNavBar({
required this.navigationShell,
required this.children,
Key? key,
}) : super(key: key ?? const ValueKey<String>('ScaffoldWithNavBar'));
/// The navigation shell and container for the branch Navigators.
final StatefulNavigationShell navigationShell;
/// The children (branch Navigators) to display in a custom container
/// ([AnimatedBranchContainer]).
final List<Widget> children;
@override
Widget build(BuildContext context) {
return Scaffold(
body: AnimatedBranchContainer(
currentIndex: navigationShell.currentIndex,
children: children,
),
bottomNavigationBar: BottomNavigationBar(
// Here, the items of BottomNavigationBar are hard coded. In a real
// world scenario, the items would most likely be generated from the
// branches of the shell route, which can be fetched using
// `navigationShell.route.branches`.
items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Section A'),
BottomNavigationBarItem(icon: Icon(Icons.work), label: 'Section B'),
],
currentIndex: navigationShell.currentIndex,
onTap: (int index) => _onTap(context, index),
),
);
}
/// Navigate to the current location of the branch at the provided index when
/// tapping an item in the BottomNavigationBar.
void _onTap(BuildContext context, int index) {
// When navigating to a new branch, it's recommended to use the goBranch
// method, as doing so makes sure the last navigation state of the
// Navigator for the branch is restored.
navigationShell.goBranch(
index,
// A common pattern when using bottom navigation bars is to support
// navigating to the initial location when tapping the item that is
// already active. This example demonstrates how to support this behavior,
// using the initialLocation parameter of goBranch.
initialLocation: index == navigationShell.currentIndex,
);
}
}
/// Custom branch Navigator container that provides animated transitions
/// when switching branches.
class AnimatedBranchContainer extends StatelessWidget {
/// Creates a AnimatedBranchContainer
const AnimatedBranchContainer(
{super.key, required this.currentIndex, required this.children});
/// The index (in [children]) of the branch Navigator to display.
final int currentIndex;
/// The children (branch Navigators) to display in this container.
final List<Widget> children;
@override
Widget build(BuildContext context) {
return Stack(
children: children.mapIndexed(
(int index, Widget navigator) {
return AnimatedScale(
scale: index == currentIndex ? 1 : 1.5,
duration: const Duration(milliseconds: 400),
child: AnimatedOpacity(
opacity: index == currentIndex ? 1 : 0,
duration: const Duration(milliseconds: 400),
child: _branchNavigatorWrapper(index, navigator),
),
);
},
).toList());
}
Widget _branchNavigatorWrapper(int index, Widget navigator) => IgnorePointer(
ignoring: index != currentIndex,
child: TickerMode(
enabled: index == currentIndex,
child: navigator,
),
);
}
/// The details screen for either the A or B screen.
class DetailsScreen extends StatefulWidget {
/// Constructs a [DetailsScreen].
const DetailsScreen({
required this.label,
this.param,
this.extra,
super.key,
});
/// The label to display in the center of the screen.
final String label;
/// Optional param
final String? param;
/// Optional extra object
final Object? extra;
@override
State<StatefulWidget> createState() => DetailsScreenState();
}
/// The state for DetailsScreen
class DetailsScreenState extends State<DetailsScreen> {
int _counter = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Details Screen - ${widget.label}'),
),
body: _build(context),
);
}
Widget _build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text('Details for ${widget.label} - Counter: $_counter',
style: Theme.of(context).textTheme.titleLarge),
const Padding(padding: EdgeInsets.all(4)),
TextButton(
onPressed: () {
setState(() {
_counter++;
});
},
child: const Text('Increment counter'),
),
const Padding(padding: EdgeInsets.all(8)),
if (widget.param != null)
Text('Parameter: ${widget.param!}',
style: Theme.of(context).textTheme.titleMedium),
const Padding(padding: EdgeInsets.all(8)),
if (widget.extra != null)
Text('Extra: ${widget.extra!}',
style: Theme.of(context).textTheme.titleMedium),
],
),
);
}
}

View File

@ -0,0 +1,80 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: always_specify_types, public_member_api_docs
part of 'stateful_shell_route_example.dart';
// **************************************************************************
// GoRouterGenerator
// **************************************************************************
List<RouteBase> get $appRoutes => [
$myShellRouteData,
];
RouteBase get $myShellRouteData => StatefulShellRouteData.$route(
restorationScopeId: MyShellRouteData.$restorationScopeId,
navigatorContainerBuilder: MyShellRouteData.$navigatorContainerBuilder,
factory: $MyShellRouteDataExtension._fromState,
branches: [
StatefulShellBranchData.$branch(
routes: [
GoRouteData.$route(
path: '/detailsA',
factory: $DetailsARouteDataExtension._fromState,
),
],
),
StatefulShellBranchData.$branch(
navigatorKey: BranchBData.$navigatorKey,
restorationScopeId: BranchBData.$restorationScopeId,
routes: [
GoRouteData.$route(
path: '/detailsB',
factory: $DetailsBRouteDataExtension._fromState,
),
],
),
],
);
extension $MyShellRouteDataExtension on MyShellRouteData {
static MyShellRouteData _fromState(GoRouterState state) =>
const MyShellRouteData();
}
extension $DetailsARouteDataExtension on DetailsARouteData {
static DetailsARouteData _fromState(GoRouterState state) =>
const DetailsARouteData();
String get location => GoRouteData.$location(
'/detailsA',
);
void go(BuildContext context) => context.go(location);
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
void pushReplacement(BuildContext context) =>
context.pushReplacement(location);
void replace(BuildContext context) => context.replace(location);
}
extension $DetailsBRouteDataExtension on DetailsBRouteData {
static DetailsBRouteData _fromState(GoRouterState state) =>
const DetailsBRouteData();
String get location => GoRouteData.$location(
'/detailsB',
);
void go(BuildContext context) => context.go(location);
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
void pushReplacement(BuildContext context) =>
context.pushReplacement(location);
void replace(BuildContext context) => context.replace(location);
}

View File

@ -6,6 +6,7 @@ environment:
sdk: ">=2.18.0 <4.0.0"
dependencies:
collection: ^1.15.0
flutter:
sdk: flutter
go_router: ^10.0.0

View File

@ -0,0 +1,26 @@
// 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_test/flutter_test.dart';
import 'package:go_router_builder_example/stateful_shell_route_example.dart';
void main() {
testWidgets('Navigate between section A and section B',
(WidgetTester tester) async {
await tester.pumpWidget(App());
expect(find.text('Details for A - Counter: 0'), findsOneWidget);
await tester.tap(find.text('Increment counter'));
await tester.pumpAndSettle();
expect(find.text('Details for A - Counter: 1'), findsOneWidget);
await tester.tap(find.text('Section B'));
await tester.pumpAndSettle();
expect(find.text('Details for B - Counter: 0'), findsOneWidget);
await tester.tap(find.text('Section A'));
await tester.pumpAndSettle();
expect(find.text('Details for A - Counter: 1'), findsOneWidget);
});
}

View File

@ -16,6 +16,8 @@ const String _routeDataUrl = 'package:go_router/src/route_data.dart';
const Map<String, String> _annotations = <String, String>{
'TypedGoRoute': 'GoRouteData',
'TypedShellRoute': 'ShellRouteData',
'TypedStatefulShellBranch': 'StatefulShellBranchData',
'TypedStatefulShellRoute': 'StatefulShellRouteData',
};
/// A [Generator] for classes annotated with a typed go route annotation.

View File

@ -38,14 +38,17 @@ class InfoIterable extends IterableBase<String> {
class ShellRouteConfig extends RouteBaseConfig {
ShellRouteConfig._({
required this.navigatorKey,
required this.parentNavigatorKey,
required super.routeDataClass,
required super.parent,
required super.parentNavigatorKey,
}) : super._();
/// The command for calling the navigator key getter from the ShellRouteData.
final String? navigatorKey;
/// The parent navigator key.
final String? parentNavigatorKey;
@override
Iterable<String> classDeclarations() {
if (routeDataClass.unnamedConstructor == null) {
@ -68,10 +71,95 @@ class ShellRouteConfig extends RouteBaseConfig {
@override
String get routeConstructorParameters =>
navigatorKey == null ? '' : 'navigatorKey: $navigatorKey,';
'${navigatorKey == null ? '' : 'navigatorKey: $navigatorKey,'}'
'${parentNavigatorKey == null ? '' : 'parentNavigatorKey: $parentNavigatorKey,'}';
@override
String get factorConstructorParameters =>
'factory: $_extensionName._fromState,';
@override
String get routeDataClassName => 'ShellRouteData';
@override
String get dataConvertionFunctionName => r'$route';
}
/// The configuration to generate class declarations for a StatefulShellRouteData.
class StatefulShellRouteConfig extends RouteBaseConfig {
StatefulShellRouteConfig._({
required this.parentNavigatorKey,
required super.routeDataClass,
required super.parent,
required this.navigatorContainerBuilder,
required this.restorationScopeId,
}) : super._();
/// The parent navigator key.
final String? parentNavigatorKey;
/// The navigator container builder.
final String? navigatorContainerBuilder;
/// The restoration scope id.
final String? restorationScopeId;
@override
Iterable<String> classDeclarations() => <String>[
'''
extension $_extensionName on $_className {
static $_className _fromState(GoRouterState state) => const $_className();
}
'''
];
@override
String get routeConstructorParameters =>
'${parentNavigatorKey == null ? '' : 'parentNavigatorKey: $parentNavigatorKey,'}'
'${restorationScopeId == null ? '' : 'restorationScopeId: $restorationScopeId,'}'
'${navigatorContainerBuilder == null ? '' : 'navigatorContainerBuilder: $navigatorContainerBuilder,'}';
@override
String get factorConstructorParameters =>
'factory: $_extensionName._fromState,';
@override
String get routeDataClassName => 'StatefulShellRouteData';
@override
String get dataConvertionFunctionName => r'$route';
}
/// The configuration to generate class declarations for a StatefulShellBranchData.
class StatefulShellBranchConfig extends RouteBaseConfig {
StatefulShellBranchConfig._({
required this.navigatorKey,
required super.routeDataClass,
required super.parent,
this.restorationScopeId,
}) : super._();
/// The command for calling the navigator key getter from the ShellRouteData.
final String? navigatorKey;
/// The restoration scope id.
final String? restorationScopeId;
@override
Iterable<String> classDeclarations() => <String>[];
@override
String get factorConstructorParameters => '';
@override
String get routeConstructorParameters =>
'${navigatorKey == null ? '' : 'navigatorKey: $navigatorKey,'}'
'${restorationScopeId == null ? '' : 'restorationScopeId: $restorationScopeId,'}';
@override
String get routeDataClassName => 'StatefulShellBranchData';
@override
String get dataConvertionFunctionName => r'$branch';
}
/// The configuration to generate class declarations for a GoRouteData.
@ -79,9 +167,9 @@ class GoRouteConfig extends RouteBaseConfig {
GoRouteConfig._({
required this.path,
required this.name,
required this.parentNavigatorKey,
required super.routeDataClass,
required super.parent,
required super.parentNavigatorKey,
}) : super._();
/// The path of the GoRoute to be created by this configuration.
@ -90,6 +178,9 @@ class GoRouteConfig extends RouteBaseConfig {
/// The name of the GoRoute to be created by this configuration.
final String? name;
/// The parent navigator key.
final String? parentNavigatorKey;
late final Set<String> _pathParams =
pathParametersFromPattern(_rawJoinedPath);
@ -295,14 +386,22 @@ extension $_extensionName on $_className {
return enumParamTypes.map<String>(_enumMapConst);
}
@override
String get factorConstructorParameters =>
'factory: $_extensionName._fromState,';
@override
String get routeConstructorParameters => '''
path: ${escapeDartString(path)},
${name != null ? 'name: ${escapeDartString(name!)},' : ''}
${parentNavigatorKey == null ? '' : 'parentNavigatorKey: $parentNavigatorKey,'}
''';
@override
String get routeDataClassName => 'GoRouteData';
@override
String get dataConvertionFunctionName => r'$route';
}
/// Represents a `TypedGoRoute` annotation to the builder.
@ -310,7 +409,6 @@ abstract class RouteBaseConfig {
RouteBaseConfig._({
required this.routeDataClass,
required this.parent,
required this.parentNavigatorKey,
});
/// Creates a new [RouteBaseConfig] represented the annotation data in [reader].
@ -342,7 +440,7 @@ abstract class RouteBaseConfig {
// TODO(stuartmorgan): Remove this ignore once 'analyze' can be set to
// 5.2+ (when Flutter 3.4+ is on stable).
// ignore: deprecated_member_use
final bool isShellRoute = type.element.name == 'TypedShellRoute';
final String typeName = type.element.name;
final DartType typeParamType = type.typeArguments.single;
if (typeParamType is! InterfaceType) {
throw InvalidGenerationSourceError(
@ -359,20 +457,54 @@ abstract class RouteBaseConfig {
final InterfaceElement classElement = typeParamType.element;
final RouteBaseConfig value;
if (isShellRoute) {
switch (typeName) {
case 'TypedShellRoute':
value = ShellRouteConfig._(
routeDataClass: classElement,
parent: parent,
navigatorKey: _generateNavigatorKeyGetterCode(
navigatorKey: _generateParameterGetterCode(
classElement,
keyName: r'$navigatorKey',
parameterName: r'$navigatorKey',
),
parentNavigatorKey: _generateNavigatorKeyGetterCode(
parentNavigatorKey: _generateParameterGetterCode(
classElement,
keyName: r'$parentNavigatorKey',
parameterName: r'$parentNavigatorKey',
),
);
} else {
break;
case 'TypedStatefulShellRoute':
value = StatefulShellRouteConfig._(
routeDataClass: classElement,
parent: parent,
parentNavigatorKey: _generateParameterGetterCode(
classElement,
parameterName: r'$parentNavigatorKey',
),
restorationScopeId: _generateParameterGetterCode(
classElement,
parameterName: r'$restorationScopeId',
),
navigatorContainerBuilder: _generateParameterGetterCode(
classElement,
parameterName: r'$navigatorContainerBuilder',
),
);
break;
case 'TypedStatefulShellBranch':
value = StatefulShellBranchConfig._(
routeDataClass: classElement,
parent: parent,
navigatorKey: _generateParameterGetterCode(
classElement,
parameterName: r'$navigatorKey',
),
restorationScopeId: _generateParameterGetterCode(
classElement,
parameterName: r'$restorationScopeId',
),
);
break;
case 'TypedGoRoute':
final ConstantReader pathValue = reader.read('path');
if (pathValue.isNull) {
throw InvalidGenerationSourceError(
@ -380,22 +512,26 @@ abstract class RouteBaseConfig {
element: element,
);
}
final ConstantReader nameValue = reader.read('name');
value = GoRouteConfig._(
path: pathValue.stringValue,
name: nameValue.isNull ? null : nameValue.stringValue,
routeDataClass: classElement,
parent: parent,
parentNavigatorKey: _generateNavigatorKeyGetterCode(
parentNavigatorKey: _generateParameterGetterCode(
classElement,
keyName: r'$parentNavigatorKey',
parameterName: r'$parentNavigatorKey',
),
);
break;
default:
throw UnsupportedError('Unrecognized type $typeName');
}
value._children.addAll(reader.read('routes').listValue.map<RouteBaseConfig>(
(DartObject e) => RouteBaseConfig._fromAnnotation(
value._children.addAll(reader
.read(_generateChildrenGetterName(typeName))
.listValue
.map<RouteBaseConfig>((DartObject e) => RouteBaseConfig._fromAnnotation(
ConstantReader(e), element, value)));
return value;
@ -409,20 +545,23 @@ abstract class RouteBaseConfig {
/// The parent of this route config.
final RouteBaseConfig? parent;
/// The parent navigator key string that is used for initialize the
/// `RouteBase` class this config generates.
final String? parentNavigatorKey;
static String _generateChildrenGetterName(String name) {
return (name == 'TypedStatefulShellRoute' ||
name == 'StatefulShellRouteData')
? 'branches'
: 'routes';
}
static String? _generateNavigatorKeyGetterCode(
InterfaceElement classElement, {
required String keyName,
}) {
static String? _generateParameterGetterCode(InterfaceElement classElement,
{required String parameterName}) {
final String? fieldDisplayName = classElement.fields
.where((FieldElement element) {
if (!element.isStatic || element.name != parameterName) {
return false;
}
if (parameterName.toLowerCase().contains('navigatorkey')) {
final DartType type = element.type;
if (!element.isStatic ||
element.name != keyName ||
type is! ParameterizedType) {
if (type is! ParameterizedType) {
return false;
}
final List<DartType> typeArguments = type.typeArguments;
@ -430,21 +569,33 @@ abstract class RouteBaseConfig {
return false;
}
final DartType typeArgument = typeArguments.single;
if (typeArgument.getDisplayString(withNullability: false) ==
if (typeArgument.getDisplayString(withNullability: false) !=
'NavigatorState') {
return true;
}
return false;
}
}
return true;
})
.map<String>((FieldElement e) => e.displayName)
.firstOrNull;
if (fieldDisplayName == null) {
return null;
}
if (fieldDisplayName != null) {
return '${classElement.name}.$fieldDisplayName';
}
final String? methodDisplayName = classElement.methods
.where((MethodElement element) {
return element.isStatic && element.name == parameterName;
})
.map<String>((MethodElement e) => e.displayName)
.firstOrNull;
if (methodDisplayName != null) {
return '${classElement.name}.$methodDisplayName';
}
return null;
}
/// Generates all of the members that correspond to `this`.
InfoIterable generateMembers() => InfoIterable._(
members: _generateMembers().toList(),
@ -496,16 +647,13 @@ RouteBase get $_routeGetterName => ${_invokesRouteConstructor()};
final String routesBit = _children.isEmpty
? ''
: '''
routes: [${_children.map((RouteBaseConfig e) => '${e._invokesRouteConstructor()},').join()}],
${_generateChildrenGetterName(routeDataClassName)}: [${_children.map((RouteBaseConfig e) => '${e._invokesRouteConstructor()},').join()}],
''';
final String parentNavigatorKeyParameter = parentNavigatorKey == null
? ''
: 'parentNavigatorKey: $parentNavigatorKey,';
return '''
$routeDataClassName.\$route(
$routeDataClassName.$dataConvertionFunctionName(
$routeConstructorParameters
factory: $_extensionName._fromState,
$parentNavigatorKeyParameter
$factorConstructorParameters
$routesBit
)
''';
@ -518,6 +666,14 @@ $routeDataClassName.\$route(
@protected
String get routeDataClassName;
/// The function name of `RouteData` to get Routes or branches.
@protected
String get dataConvertionFunctionName;
/// Additional factory constructor.
@protected
String get factorConstructorParameters;
/// Additional constructor parameter for invoking route constructor.
@protected
String get routeConstructorParameters;

View File

@ -2,7 +2,7 @@ name: go_router_builder
description: >-
A builder that supports generated strongly-typed route helpers for
package:go_router
version: 2.2.5
version: 2.3.0
repository: https://github.com/flutter/packages/tree/main/packages/go_router_builder
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+go_router_builder%22