Update Navigation and Routing sample (#851)

* Add duration parameter to FadeTransitionPage

* Use didChangeDependencies instead of didUpdateWidget

* Don't notify listeners if the path hasn't changed

* Update navigation sample WIP

* Use Link and RouteStateScope in settings screen

* update README

* use named parameters for Library.addBook()

* Make _handleAuthStateChanged synchronous

* add missing copyright headers

* Address code review comments

* Address code review comments
pull/854/head
John Ryan 3 years ago committed by GitHub
parent d3c4645a42
commit 35f1670098
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -1,5 +1,148 @@
# bookstore
# Navigation and Routing
A sample that shows how to use the [Router][] API to handle common navigation
scenarios.
This sample shows how to set up a Router using a custom RouterDelegate and
RouteInformationParser.
## Goals
- Demonstrate common navigation scenarios:
- Parsing path parameters ('/user/:id')
- Sign in (validation / guards)
- Nested navigation
- Provide a reusable implementation of RouterDelegate and RouteInformationParser
- Demonstrate how [deep linking][] is configured on iOS and Android
- Demonstrate how to use the Link widget from `package:url_Launcher` with the
Router API.
## How it works
The top-level widget, `Bookstore`, sets up the state for this app. It places
three `InheritedNotifier` widgets in the tree: `RouteStateScope`,
`BookstoreAuthScope`, and `LibraryScope`, which provide the state for the
application:
- **`RouteState`**: stores the current route path (`/book/1`) as a `ParsedRoute`
object (see below).
- **`BookstoreAuthScope`**: stores a mock authentication API, `BookstoreAuth`.
- **`LibraryScope`**: stores the data for the app, `Library`.
The `Bookstore` widget also uses the [MaterialApp.router()][router-ctor]
constructor to opt-in to the [Router][] API. This constructor requires a
[RouterDelegate][] and [RouteInformationParser][]. This app uses the
`routing.dart` library, described below.
## routing.dart
This library contains a general-purpose routing solution for medium-sized apps.
It implements these classes:
- **`SimpleRouterDelegate`**: Implements `RouterDelegate`. Updates `RouteState` when
a new route has been pushed to the application by the operating system. Also
notifies the `Router` widget whenever the `RouteState` changes.
- **`TemplateRouteParser`**: Implements RouteInformationParser. Parses the
incoming route path into a `ParsedRoute` object. A `RouteGuard` can be
provided to guard access to certain routes.
- **`ParsedRoute`**: Contains the current route location ("/user/2"), path
parameters ({id: 2}), query parameters ("?search=abc"), and path template
("/user/:id")
- **`RouteState`**: Stores the current `ParsedRoute`.
- **`RouteGuard`**: Guards access to routes. Can be overridden to redirect the
incoming route if a condition isn't met.
## App Structure
The `SimpleRouterDelegate` constructor requires a `WidgetBuilder` parameter and
a `navigatorKey`. This app uses a `BookstoreNavigator` widget, which configures
a `Navigator` with a list of pages, based on the current `RouteState`.
```dart
SimpleRouterDelegate(
routeState: routeState,
navigatorKey: navigatorKey,
builder: (context) => BookstoreNavigator(
navigatorKey: navigatorKey,
),
);
```
This `Navigator` is configured to display either the sign-in screen or the
`BookstoreScaffold`. An additional screen is stacked on top of the
`BookstoreScaffold` if a book or author is currently selected:
```dart
return Navigator(
key: widget.navigatorKey,
onPopPage: (route, dynamic result) {
// ...
},
pages: [
if (routeState.route.pathTemplate == '/signin')
FadeTransitionPage<void>(
key: signInKey,
child: SignInScreen(),
),
else ...[
FadeTransitionPage<void>(
key: scaffoldKey,
child: BookstoreScaffold(),
),
if (selectedBook != null)
MaterialPage<void>(
key: bookDetailsKey,
child: BookDetailsScreen(
book: selectedBook,
),
)
else if (selectedAuthor != null)
MaterialPage<void>(
key: authorDetailsKey,
child: AuthorDetailsScreen(
author: selectedAuthor,
),
),
],
],
);
```
The `BookstoreScaffold` widget uses `package:adaptive_navigation` to build a
navigation rail or bottom navigation bar based on the size of the screen. The
body of this screen is `BookstoreScaffoldBody`, which configures a nested
Navigator to display either the `AuthorsScreen`, `SettingsScreen`, or
`BooksScreen` widget.
## Linking vs updating RouteState
There are two ways to change the current route, either by updating `RouteState`,
which the RouterDelegate listens to, or use the Link widget from
`package:url_launcher`. The `SettingsScreen` widget demonstrates both options:
```
Link(
uri: Uri.parse('/book/0'),
builder: (context, followLink) {
return TextButton(
child: const Text('Go directly to /book/0 (Link)'),
onPressed: followLink,
);
},
),
TextButton(
child: const Text('Go directly to /book/0 (RouteState)'),
onPressed: () {
RouteStateScope.of(context)!.go('/book/0');
},
),
```
## Questions/issues
If you have a general question about the Router API, the best places to go are:
- [The FlutterDev Google Group](https://groups.google.com/forum/#!forum/flutter-dev)
- [StackOverflow](https://stackoverflow.com/questions/tagged/flutter)
If you run into an issue with the sample itself, please file an issue
in the [main Flutter repo](https://github.com/flutter/flutter/issues).
[Router]: https://api.flutter.dev/flutter/widgets/Router-class.html
[RouterDelegate]: https://api.flutter.dev/flutter/widgets/RouterDelegate-class.html
[RouteInformationParser]: https://api.flutter.dev/flutter/widgets/RouteInformationParser-class.html
[router-ctor]: https://api.flutter.dev/flutter/material/MaterialApp/MaterialApp.router.html
[deep linking]: https://flutter.dev/docs/development/ui/navigation/deep-linking

@ -10,6 +10,14 @@ import 'src/app.dart';
void main() {
// Use package:url_strategy until this pull request is released:
// https://github.com/flutter/flutter/pull/77103
setPathUrlStrategy();
// Use to setHashUrlStrategy() to use "/#/" in the address bar (default). Use
// setPathUrlStrategy() to use the path. You may need to configure your web
// server to redirect all paths to index.html.
//
// On mobile platforms, both functions are no-ops.
setHashUrlStrategy();
// setPathUrlStrategy();
runApp(const Bookstore());
}

@ -26,10 +26,26 @@ class _BookstoreState extends State<Bookstore> {
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
final library = Library()
..addBook('Left Hand of Darkness', 'Ursula K. Le Guin', true, true)
..addBook('Too Like the Lightning', 'Ada Palmer', false, true)
..addBook('Kindred', 'Octavia E. Butler', true, false)
..addBook('The Lathe of Heaven', 'Ursula K. Le Guin', false, false);
..addBook(
title: 'Left Hand of Darkness',
authorName: 'Ursula K. Le Guin',
isPopular: true,
isNew: true)
..addBook(
title: 'Too Like the Lightning',
authorName: 'Ada Palmer',
isPopular: false,
isNew: true)
..addBook(
title: 'Kindred',
authorName: 'Octavia E. Butler',
isPopular: true,
isNew: false)
..addBook(
title: 'The Lathe of Heaven',
authorName: 'Ursula K. Le Guin',
isPopular: false,
isNew: false);
@override
void initState() {
@ -37,7 +53,7 @@ class _BookstoreState extends State<Bookstore> {
/// Configure the parser with all of the app's allowed path templates.
routeParser = TemplateRouteParser(
[
allowedPaths: [
'/signin',
'/authors',
'/settings',
@ -58,7 +74,6 @@ class _BookstoreState extends State<Bookstore> {
navigatorKey: navigatorKey,
builder: (context) => BookstoreNavigator(
navigatorKey: navigatorKey,
auth: auth,
),
);
@ -85,7 +100,7 @@ class _BookstoreState extends State<Bookstore> {
);
}
Future<void> _handleAuthStateChanged() async {
void _handleAuthStateChanged() {
if (!auth.signedIn) {
routeState.go('/signin');
}

@ -1,2 +1,6 @@
// Copyright 2021, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
export 'auth/auth.dart';
export 'auth/auth_guard.dart';

@ -1,3 +1,7 @@
// Copyright 2021, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
export 'data/author.dart';
export 'data/book.dart';
export 'data/library.dart';

@ -11,7 +11,12 @@ class Library {
final List<Book> allBooks = [];
final List<Author> allAuthors = [];
void addBook(String title, String authorName, bool isPopular, bool isNew) {
void addBook({
required String title,
required String authorName,
required bool isPopular,
required bool isNew,
}) {
var author =
allAuthors.firstWhereOrNull((author) => author.name == authorName);
var book = Book(allBooks.length, title, isPopular, isNew);

@ -1,3 +1,7 @@
// Copyright 2021, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
export 'routing/delegate.dart';
export 'routing/parsed_route.dart';
export 'routing/parser.dart';

@ -9,9 +9,16 @@ import 'parser.dart';
/// A route path that has been parsed by [TemplateRouteParser].
class ParsedRoute {
/// The current path location without query parameters. (/book/123)
final String path;
/// The path template (/book/:id)
final String pathTemplate;
/// The path parameters ({id: 123})
final Map<String, String> parameters;
/// The query parameters ({search: abc})
final Map<String, String> queryParameters;
static const _mapEquality = MapEquality<String, String>();

@ -7,6 +7,11 @@ import 'package:path_to_regexp/path_to_regexp.dart';
import 'parsed_route.dart';
/// Used by [TemplateRouteParser] to guard access to routes.
///
/// Override this class to change the route that is returned by
/// [TemplateRouteParser.parseRouteInformation] if a condition is not met, for
/// example, if the user is not signed in.
abstract class RouteGuard<T> {
Future<T> redirect(T from);
}
@ -17,11 +22,16 @@ class TemplateRouteParser extends RouteInformationParser<ParsedRoute> {
RouteGuard<ParsedRoute>? guard;
final ParsedRoute initialRoute;
TemplateRouteParser(List<String> pathTemplates,
{String? initialRoute = '/', this.guard})
: initialRoute =
TemplateRouteParser({
/// The list of allowed path templates (['/', '/users/:id'])
required List<String> allowedPaths,
/// The initial route
String? initialRoute = '/',
/// [RouteGuard] used to redirect.
this.guard,
}) : initialRoute =
ParsedRoute(initialRoute ?? '/', initialRoute ?? '/', {}, {}) {
for (var template in pathTemplates) {
for (var template in allowedPaths) {
_addRoute(template);
}
}

@ -8,11 +8,11 @@ import 'package:flutter/widgets.dart';
import 'parsed_route.dart';
import 'parser.dart';
/// The current route state. To change the current route, call obtain the state using
/// `RouteState.of(context)` and call `go()`:
/// The current route state. To change the current route, call obtain the state
/// using `RouteStateScope.of(context)` and call `go()`:
///
/// ```
/// RouteState.of(context).go('/book/2');
/// RouteStateScope.of(context).go('/book/2');
/// ```
class RouteState extends ChangeNotifier {
TemplateRouteParser parser;
@ -24,6 +24,9 @@ class RouteState extends ChangeNotifier {
ParsedRoute get route => _route;
set route(ParsedRoute route) {
// Don't notify listeners if the path hasn't changed.
if (_route == route) return;
_route = route;
notifyListeners();
}

@ -21,7 +21,7 @@ class BookDetailsScreen extends StatelessWidget {
if (book == null) {
return const Scaffold(
body: Center(
child: Text('No book with found.'),
child: Text('No book found.'),
),
);
}

@ -32,6 +32,20 @@ class _BooksScreenState extends State<BooksScreen>
..addListener(_handleTabIndexChanged);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final newPath = routeState.route.pathTemplate;
if (newPath.startsWith('/books/popular')) {
_tabController.index = 0;
} else if (newPath.startsWith('/books/new')) {
_tabController.index = 1;
} else if (newPath == '/books/all') {
_tabController.index = 2;
}
}
@override
void dispose() {
_tabController.removeListener(_handleTabIndexChanged);
@ -114,17 +128,4 @@ class _BooksScreenState extends State<BooksScreen>
break;
}
}
@override
void didUpdateWidget(BooksScreen oldWidget) {
var newPath = routeState.route.pathTemplate;
if (newPath.startsWith('/books/popular')) {
_tabController.index = 0;
} else if (newPath.startsWith('/books/new')) {
_tabController.index = 1;
} else if (newPath == '/books/all') {
_tabController.index = 2;
}
super.didUpdateWidget(oldWidget);
}
}

@ -1,3 +1,7 @@
// Copyright 2021, the Flutter project authors. Please see the AUTHORS file
// for details. 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:collection/collection.dart';
import 'package:flutter/material.dart';
@ -15,10 +19,8 @@ import 'scaffold.dart';
/// on the [routeState] that was parsed by the TemplateRouteParser.
class BookstoreNavigator extends StatefulWidget {
final GlobalKey<NavigatorState> navigatorKey;
final BookstoreAuth auth;
const BookstoreNavigator({
required this.auth,
required this.navigatorKey,
Key? key,
}) : super(key: key);
@ -28,6 +30,7 @@ class BookstoreNavigator extends StatefulWidget {
}
class _BookstoreNavigatorState extends State<BookstoreNavigator> {
final signInKey = const ValueKey('Sign in');
final scaffoldKey = const ValueKey<String>('App scaffold');
final bookDetailsKey = const ValueKey<String>('Book details screen');
final authorDetailsKey = const ValueKey<String>('Author details screen');
@ -35,18 +38,19 @@ class _BookstoreNavigatorState extends State<BookstoreNavigator> {
@override
Widget build(BuildContext context) {
final routeState = RouteStateScope.of(context)!;
final authState = BookstoreAuthScope.of(context)!;
final pathTemplate = routeState.route.pathTemplate;
final library = LibraryScope.of(context);
Book? book;
Book? selectedBook;
if (pathTemplate == '/book/:bookId') {
book = library.allBooks.firstWhereOrNull(
selectedBook = library.allBooks.firstWhereOrNull(
(b) => b.id.toString() == routeState.route.parameters['bookId']);
}
Author? author;
Author? selectedAuthor;
if (pathTemplate == '/author/:authorId') {
author = library.allAuthors.firstWhereOrNull(
selectedAuthor = library.allAuthors.firstWhereOrNull(
(b) => b.id.toString() == routeState.route.parameters['authorId']);
}
@ -71,11 +75,11 @@ class _BookstoreNavigatorState extends State<BookstoreNavigator> {
if (routeState.route.pathTemplate == '/signin')
// Display the sign in screen.
FadeTransitionPage<void>(
key: const ValueKey('Sign in'),
key: signInKey,
child: SignInScreen(
onSignIn: (credentials) async {
var signedIn = await widget.auth
.signIn(credentials.username, credentials.password);
var signedIn = await authState.signIn(
credentials.username, credentials.password);
if (signedIn) {
routeState.go('/books/popular');
}
@ -90,18 +94,18 @@ class _BookstoreNavigatorState extends State<BookstoreNavigator> {
),
// Add an additional page to the stack if the user is viewing a book
// or an author
if (book != null)
if (selectedBook != null)
MaterialPage<void>(
key: bookDetailsKey,
child: BookDetailsScreen(
book: book,
book: selectedBook,
),
)
else if (author != null)
else if (selectedAuthor != null)
MaterialPage<void>(
key: authorDetailsKey,
child: AuthorDetailsScreen(
author: author,
author: selectedAuthor,
),
),
],

@ -2,6 +2,7 @@
// for details. 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:bookstore/src/routing.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/link.dart';
@ -62,18 +63,15 @@ class SettingsContent extends StatelessWidget {
uri: Uri.parse('/book/0'),
builder: (context, followLink) {
return TextButton(
child: const Text('Go directly to /book/0'),
child: const Text('Go directly to /book/0 (Link)'),
onPressed: followLink,
);
},
),
Link(
uri: Uri.parse('/author/0'),
builder: (context, followLink) {
return TextButton(
child: const Text('Go directly to /author/0'),
onPressed: followLink,
);
TextButton(
child: const Text('Go directly to /book/0 (RouteState)'),
onPressed: () {
RouteStateScope.of(context)!.go('/book/0');
},
),
].map((w) => Padding(padding: const EdgeInsets.all(8), child: w)),

@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
class Credentials {
final String username;
final String password;
Credentials(this.username, this.password);
}
@ -24,8 +25,8 @@ class SignInScreen extends StatefulWidget {
}
class _SignInScreenState extends State<SignInScreen> {
String username = '';
String password = '';
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
@override
Widget build(BuildContext context) {
@ -42,26 +43,20 @@ class _SignInScreenState extends State<SignInScreen> {
Text('Sign in', style: Theme.of(context).textTheme.headline4),
TextField(
decoration: const InputDecoration(labelText: 'Username'),
onChanged: (v) {
setState(() {
username = v;
});
},
controller: _usernameController,
),
TextField(
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
onChanged: (v) {
setState(() {
password = v;
});
},
controller: _passwordController,
),
Padding(
padding: const EdgeInsets.all(16),
child: TextButton(
onPressed: () async {
widget.onSignIn(Credentials(username, password));
widget.onSignIn(Credentials(
_usernameController.value.text,
_passwordController.value.text));
},
child: const Text('Sign in'),
),

@ -6,9 +6,13 @@ import 'package:flutter/material.dart';
class FadeTransitionPage<T> extends Page<T> {
final Widget child;
final Duration duration;
const FadeTransitionPage({LocalKey? key, required this.child})
: super(key: key);
const FadeTransitionPage({
LocalKey? key,
required this.child,
this.duration = const Duration(milliseconds: 300),
}) : super(key: key);
@override
Route<T> createRoute(BuildContext context) {
@ -17,10 +21,9 @@ class FadeTransitionPage<T> extends Page<T> {
}
class PageBasedFadeTransitionRoute<T> extends PageRoute<T> {
PageBasedFadeTransitionRoute(Page page)
: super(
settings: page,
);
final FadeTransitionPage<T> page;
PageBasedFadeTransitionRoute(this.page) : super(settings: page);
@override
Color? get barrierColor => null;
@ -29,7 +32,7 @@ class PageBasedFadeTransitionRoute<T> extends PageRoute<T> {
String? get barrierLabel => null;
@override
Duration get transitionDuration => const Duration(milliseconds: 300);
Duration get transitionDuration => page.duration;
@override
bool get maintainState => true;

@ -9,10 +9,26 @@ void main() {
group('Library', () {
test('addBook', () {
final library = Library();
library.addBook('Left Hand of Darkness', 'Ursula K. Le Guin', true, true);
library.addBook('Too Like the Lightning', 'Ada Palmer', false, true);
library.addBook('Kindred', 'Octavia E. Butler', true, false);
library.addBook('The Lathe of Heaven', 'Ursula K. Le Guin', false, false);
library.addBook(
title: 'Left Hand of Darkness',
authorName: 'Ursula K. Le Guin',
isPopular: true,
isNew: true);
library.addBook(
title: 'Too Like the Lightning',
authorName: 'Ada Palmer',
isPopular: false,
isNew: true);
library.addBook(
title: 'Kindred',
authorName: 'Octavia E. Butler',
isPopular: true,
isNew: false);
library.addBook(
title: 'The Lathe of Heaven',
authorName: 'Ursula K. Le Guin',
isPopular: false,
isNew: false);
expect(library.allAuthors.length, 3);
expect(library.allAuthors.first.books.length, 2);
expect(library.allBooks.length, 4);

Loading…
Cancel
Save