• Github 中文镜像
Sign inSign up
Watch966
Star102.4k
Fork61.8k
Tag: flutter
Switch branches/tags
K / Flutter 路由 2.0(二).md
移动浏览 Clone
加载中...
到移动设备上浏览
817 lines 46.77 KB
First commit on 2 Nov 2020

    翻自:学习 Flutter 的新导航和路由系统

    原文太长,拆成两篇,上一篇是说明,这一篇是练习。

    Navigator 2.0 练习

    本节将引导您完成使用 Navigator 2.0 API 的练习。我们将最终获得一个可以与 URL 栏保持同步的应用程序,并处理该应用程序和浏览器中的后退按钮按下操作,如以下GIF所示:

    看不见图的话请翻和谐墙

    要继续学习,请切换到 master channel,使用 Web 支持创建一个新的 Flutter 项目,并将 lib/main.dart 的内容替换为以下内容:

    import 'package:flutter/material.dart';
    
    void main() {
      runApp(BooksApp());
    }
    
    class Book {
      final String title;
      final String author;
    
      Book(this.title, this.author);
    }
    
    class BooksApp extends StatefulWidget {
      @override
      State<StatefulWidget> createState() => _BooksAppState();
    }
    
    class _BooksAppState extends State<BooksApp> {
      void initState() {
        super.initState();
      }
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'Books App',
          home: Navigator(
            pages: [
              MaterialPage(
                key: ValueKey('BooksListPage'),
                child: Scaffold(),
              )
            ],
            onPopPage: (route, result) => route.didPop(result),
          ),
        );
      }
    }
    

    Pages

    Navigator 在其构造函数中有一个新的 pages 参数。如果 Page 对象的列表更改,则 Navigator 将更新路由堆栈以进行匹配。为了了解其工作原理,我们将构建一个显示书籍列表的应用。

    _BooksAppState 中,保留两种状态:书籍列表和所选书籍:

    class _BooksAppState extends State<BooksApp> {
      // New:
      Book _selectedBook;
      bool show404 = false;
      List<Book> books = [
        Book('Stranger in a Strange Land', 'Robert A. Heinlein'),
        Book('Foundation', 'Isaac Asimov'),
        Book('Fahrenheit 451', 'Ray Bradbury'),
      ];
      
      // ...
    

    然后在 _BooksAppState 中,返回带有 Page 对象列表的 Navigator

    @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'Books App',
          home: Navigator(
            pages: [
              MaterialPage(
                key: ValueKey('BooksListPage'),
                child: BooksListScreen(
                  books: books,
                  onTapped: _handleBookTapped,
                ),
              ),
            ],
          ),
        );
      }
    void _handleBookTapped(Book book) {
        setState(() {
          _selectedBook = book;
        });
      }
    // ...
    class BooksListScreen extends StatelessWidget {
      final List<Book> books;
      final ValueChanged<Book> onTapped;
    BooksListScreen({
        @required this.books,
        @required this.onTapped,
      });
    @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(),
          body: ListView(
            children: [
              for (var book in books)
                ListTile(
                  title: Text(book.title),
                  subtitle: Text(book.author),
                  onTap: () => onTapped(book),
                )
            ],
          ),
        );
      }
    }
    

    由于此应用有两屏,一个书的列表和一个显示详细信息,因此如果选择了书,则添加第二个(详细)页面(使用 collection if):

    pages: [
      MaterialPage(
        key: ValueKey('BooksListPage'),
        child: BooksListScreen(
          books: books,
          onTapped: _handleBookTapped,
        ),
      ),
    // New:
      if (show404)
        MaterialPage(key: ValueKey('UnknownPage'), child: UnknownScreen())
      else if (_selectedBook != null)
        MaterialPage(
            key: ValueKey(_selectedBook),
            child: BookDetailsScreen(book: _selectedBook))
    ],
    

    请注意,页面的 keybook 对象的值定义。这告诉 Navigator,当 Book 对象不同时,此 MaterialPage 对象也与另一个对象不同。没有唯一 key,框架将无法确定何时显示不同 Pages 之间的过渡动画。

    注意:如果愿意,还可以扩展 Page 来自定义行为。例如,此页面添加了一个自定义过渡动画:

    class BookDetailsPage extends Page {
      final Book book;
      
      BookDetailsPage({
        this.book,
      }) : super(key: ValueKey(book));
      
      Route createRoute(BuildContext context) {
        return PageRouteBuilder(
          settings: this,
          pageBuilder: (context, animation, animation2) {
            final tween = Tween(begin: Offset(0.0, 1.0), end: Offset.zero);
            final curveTween = CurveTween(curve: Curves.easeInOut);
            return SlideTransition(
              position: animation.drive(curveTween).drive(tween),
              child: BookDetailsScreen(
                key: ValueKey(book),
                book: book,
              ),
            );
          },
        );
      }
    }
    

    最后,提供 pages 参数而不提供 onPopPage 回调是错误的。每当调用 Navigator.pop() 时,都会调用此函数。它应该用于更新状态(确定页面列表),并且必须在路由上调用 didPop 以确定弹出是否成功:

    onPopPage: (route, result) {
      if (!route.didPop(result)) {
        return false;
      }
    
      // Update the list of pages by setting _selectedBook to null
      setState(() {
        _selectedBook = null;
      });
    
      return true;
    },
    

    在更新应用程序状态之前,请务必检查 didPop 是否失败。

    使用 setState 通知框架调用 build() 方法,当 _selectedBooknull 时,该方法返回一个包含单个页面的列表。

    这是完整的例子:

    import 'package:flutter/material.dart';
    
    void main() {
      runApp(BooksApp());
    }
    
    class Book {
      final String title;
      final String author;
    
      Book(this.title, this.author);
    }
    
    class BooksApp extends StatefulWidget {
      @override
      State<StatefulWidget> createState() => _BooksAppState();
    }
    
    class _BooksAppState extends State<BooksApp> {
      Book _selectedBook;
    
      List<Book> books = [
        Book('Stranger in a Strange Land', 'Robert A. Heinlein'),
        Book('Foundation', 'Isaac Asimov'),
        Book('Fahrenheit 451', 'Ray Bradbury'),
      ];
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'Books App',
          home: Navigator(
            pages: [
              MaterialPage(
                key: ValueKey('BooksListPage'),
                child: BooksListScreen(
                  books: books,
                  onTapped: _handleBookTapped,
                ),
              ),
              if (_selectedBook != null) BookDetailsPage(book: _selectedBook)
            ],
            onPopPage: (route, result) {
              if (!route.didPop(result)) {
                return false;
              }
    
              // Update the list of pages by setting _selectedBook to null
              setState(() {
                _selectedBook = null;
              });
    
              return true;
            },
          ),
        );
      }
    
      void _handleBookTapped(Book book) {
        setState(() {
          _selectedBook = book;
        });
      }
    }
    
    class BookDetailsPage extends Page {
      final Book book;
    
      BookDetailsPage({
        this.book,
      }) : super(key: ValueKey(book));
    
      Route createRoute(BuildContext context) {
        return MaterialPageRoute(
          settings: this,
          builder: (BuildContext context) {
            return BookDetailsScreen(book: book);
          },
        );
      }
    }
    
    class BooksListScreen extends StatelessWidget {
      final List<Book> books;
      final ValueChanged<Book> onTapped;
    
      BooksListScreen({
        @required this.books,
        @required this.onTapped,
      });
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(),
          body: ListView(
            children: [
              for (var book in books)
                ListTile(
                  title: Text(book.title),
                  subtitle: Text(book.author),
                  onTap: () => onTapped(book),
                )
            ],
          ),
        );
      }
    }
    
    class BookDetailsScreen extends StatelessWidget {
      final Book book;
    
      BookDetailsScreen({
        @required this.book,
      });
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(),
          body: Padding(
            padding: const EdgeInsets.all(8.0),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                if (book != null) ...[
                  Text(book.title, style: Theme.of(context).textTheme.headline6),
                  Text(book.author, style: Theme.of(context).textTheme.subtitle1),
                ],
              ],
            ),
          ),
        );
      }
    }
    

    就目前而言,此应用仅使我们能够以声明方式定义页面堆栈。我们无法处理平台的后退按钮,并且在导航时浏览器的 URL 不会更改。

    Router

    到目前为止,该应用可以显示不同的页面,但无法处理来自基础平台的路由,例如,如果用户在浏览器中更新了网址,

    本节显示如何实现 RouteInformationParserRouterDelegate 以及更新应用程序状态。设置完成后,该应用程序将与浏览器的 URL 保持同步。

    数据类型

    RouteInformationParser 将路由信息解析为用户定义的数据类型,因此我们首先定义该类型:

    class BookRoutePath {
      final int id;
      final bool isUnknown;
    
      BookRoutePath.home()
          : id = null,
            isUnknown = false;
    
      BookRoutePath.details(this.id) : isUnknown = false;
    
      BookRoutePath.unknown()
          : id = null,
            isUnknown = true;
    
      bool get isHomePage => id == null;
    
      bool get isDetailsPage => id != null;
    }
    

    在此应用程序中,可以使用单个类表示应用程序中的所有路由。相反,您可以选择使用实现超类的不同类,或以其他方式管理路由信息。

    RouterDelegate

    接下来,添加一个扩展 RouterDelegate 的类:

    class BookRouterDelegate extends RouterDelegate<BookRoutePath>
        with ChangeNotifier, PopNavigatorRouterDelegateMixin<BookRoutePath> {
      @override
      Widget build(BuildContext context) {
        // TODO
        throw UnimplementedError();
      }
    
      @override
      // TODO
      GlobalKey<NavigatorState> get navigatorKey => throw UnimplementedError();
    
      @override
      Future<void> setNewRoutePath(BookRoutePath configuration) {
        // TODO
        throw UnimplementedError();
      }
    }
    

    RouterDelegate 上定义的通用类型是 BookRoutePath,它包含决定显示哪些页面所需的所有状态。

    我们需要将一些逻辑从 _BooksAppState 移到 BookRouterDelegate,然后创建一个 GlobalKey。在此示例中,应用程序状态直接存储在 RouterDelegate 上,但也可以分为另一个类。

    class BookRouterDelegate extends RouterDelegate<BookRoutePath>
        with ChangeNotifier, PopNavigatorRouterDelegateMixin<BookRoutePath> {
      final GlobalKey<NavigatorState> navigatorKey;
    
      Book _selectedBook;
      bool show404 = false;
    
      List<Book> books = [
        Book('Stranger in a Strange Land', 'Robert A. Heinlein'),
        Book('Foundation', 'Isaac Asimov'),
        Book('Fahrenheit 451', 'Ray Bradbury'),
      ];
    
      BookRouterDelegate() : navigatorKey = GlobalKey<NavigatorState>();
      // ...
    

    为了在 URL 中显示正确的路径,我们需要根据应用程序的当前状态返回 BookRoutePath

      BookRoutePath get currentConfiguration {
        if (show404) {
          return BookRoutePath.unknown();
        }
    
        return _selectedBook == null
            ? BookRoutePath.home()
            : BookRoutePath.details(books.indexOf(_selectedBook));
      }
    

    接下来,RouterDelegate 中的 build 方法需要返回一个 Navigator

    @override
    Widget build(BuildContext context) {
      return Navigator(
        key: navigatorKey,
        pages: [
          MaterialPage(
            key: ValueKey('BooksListPage'),
            child: BooksListScreen(
              books: books,
              onTapped: _handleBookTapped,
            ),
          ),
          if (show404)
            MaterialPage(key: ValueKey('UnknownPage'), child: UnknownScreen())
          else if (_selectedBook != null)
            BookDetailsPage(book: _selectedBook)
        ],
        onPopPage: (route, result) {
          if (!route.didPop(result)) {
            return false;
          }
    
          // Update the list of pages by setting _selectedBook to null
          _selectedBook = null;
          show404 = false;
          notifyListeners();
    
          return true;
        },
      );
    }
    

    现在,onPopPage 回调使用 notifyListeners 代替 setState,因为此类现在是 ChangeNotifier 而不是小部件。当 RouterDelegate 通知其侦听器时,同样会通知 Router 窗口小部件 RouterDelegatecurrentConfiguration 已更改,并且需要再次调用其构建方法来构建新的 Navigator

    _handleBookTapped 方法还需要使用 notifyListeners 而不是 setState

      void _handleBookTapped(Book book) {
        _selectedBook = book;
        notifyListeners();
      }
    

    将新路由推送到应用程序后,Router 调用 setNewRoutePath,这使我们的应用程序有机会根据对路由的更改来更新应用程序状态:

      @override
      Future<void> setNewRoutePath(BookRoutePath path) async {
        if (path.isUnknown) {
          _selectedBook = null;
          show404 = true;
          return;
        }
    
        if (path.isDetailsPage) {
          if (path.id < 0 || path.id > books.length - 1) {
            show404 = true;
            return;
          }
    
          _selectedBook = books[path.id];
        } else {
          _selectedBook = null;
        }
    
        show404 = false;
      }
    

    RouteInformationParser

    RouteInformationParser 提供了一个钩子,用于解析传入的路由(RouteInformation)并将其转换为用户定义的类型(BookRoutePath)。使用 Uri 类进行解析:

    class BookRouteInformationParser extends RouteInformationParser<BookRoutePath> {
      @override
      Future<BookRoutePath> parseRouteInformation(
          RouteInformation routeInformation) async {
        final uri = Uri.parse(routeInformation.location);
        // Handle '/'
        if (uri.pathSegments.length == 0) {
          return BookRoutePath.home();
        }
    
        // Handle '/book/:id'
        if (uri.pathSegments.length == 2) {
          if (uri.pathSegments[0] != 'book') return BookRoutePath.unknown();
          var remaining = uri.pathSegments[1];
          var id = int.tryParse(remaining);
          if (id == null) return BookRoutePath.unknown();
          return BookRoutePath.details(id);
        }
    
        // Handle unknown routes
        return BookRoutePath.unknown();
      }
    
      @override
      RouteInformation restoreRouteInformation(BookRoutePath path) {
        if (path.isUnknown) {
          return RouteInformation(location: '/404');
        }
        if (path.isHomePage) {
          return RouteInformation(location: '/');
        }
        if (path.isDetailsPage) {
          return RouteInformation(location: '/book/${path.id}');
        }
        return null;
      }
    }
    

    此实现特定于此应用,而不是常规的路由解析解决方案。以后再说。

    要使用这些新类,我们使用新的 MaterialApp.router 构造函数并传入我们的自定义实现:

        return MaterialApp.router(
          title: 'Books App',
          routerDelegate: _routerDelegate,
          routeInformationParser: _routeInformationParser,
        );
    

    这是完整的例子:

    import 'package:flutter/material.dart';
    
    void main() {
      runApp(BooksApp());
    }
    
    class Book {
      final String title;
      final String author;
    
      Book(this.title, this.author);
    }
    
    class BooksApp extends StatefulWidget {
      @override
      State<StatefulWidget> createState() => _BooksAppState();
    }
    
    class _BooksAppState extends State<BooksApp> {
      BookRouterDelegate _routerDelegate = BookRouterDelegate();
      BookRouteInformationParser _routeInformationParser =
          BookRouteInformationParser();
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp.router(
          title: 'Books App',
          routerDelegate: _routerDelegate,
          routeInformationParser: _routeInformationParser,
        );
      }
    }
    
    class BookRouteInformationParser extends RouteInformationParser<BookRoutePath> {
      @override
      Future<BookRoutePath> parseRouteInformation(
          RouteInformation routeInformation) async {
        final uri = Uri.parse(routeInformation.location);
        // Handle '/'
        if (uri.pathSegments.length == 0) {
          return BookRoutePath.home();
        }
    
        // Handle '/book/:id'
        if (uri.pathSegments.length == 2) {
          if (uri.pathSegments[0] != 'book') return BookRoutePath.unknown();
          var remaining = uri.pathSegments[1];
          var id = int.tryParse(remaining);
          if (id == null) return BookRoutePath.unknown();
          return BookRoutePath.details(id);
        }
    
        // Handle unknown routes
        return BookRoutePath.unknown();
      }
    
      @override
      RouteInformation restoreRouteInformation(BookRoutePath path) {
        if (path.isUnknown) {
          return RouteInformation(location: '/404');
        }
        if (path.isHomePage) {
          return RouteInformation(location: '/');
        }
        if (path.isDetailsPage) {
          return RouteInformation(location: '/book/${path.id}');
        }
        return null;
      }
    }
    
    class BookRouterDelegate extends RouterDelegate<BookRoutePath>
        with ChangeNotifier, PopNavigatorRouterDelegateMixin<BookRoutePath> {
      final GlobalKey<NavigatorState> navigatorKey;
    
      Book _selectedBook;
      bool show404 = false;
    
      List<Book> books = [
        Book('Stranger in a Strange Land', 'Robert A. Heinlein'),
        Book('Foundation', 'Isaac Asimov'),
        Book('Fahrenheit 451', 'Ray Bradbury'),
      ];
    
      BookRouterDelegate() : navigatorKey = GlobalKey<NavigatorState>();
    
      BookRoutePath get currentConfiguration {
        if (show404) {
          return BookRoutePath.unknown();
        }
        return _selectedBook == null
            ? BookRoutePath.home()
            : BookRoutePath.details(books.indexOf(_selectedBook));
      }
    
      @override
      Widget build(BuildContext context) {
        return Navigator(
          key: navigatorKey,
          pages: [
            MaterialPage(
              key: ValueKey('BooksListPage'),
              child: BooksListScreen(
                books: books,
                onTapped: _handleBookTapped,
              ),
            ),
            if (show404)
              MaterialPage(key: ValueKey('UnknownPage'), child: UnknownScreen())
            else if (_selectedBook != null)
              BookDetailsPage(book: _selectedBook)
          ],
          onPopPage: (route, result) {
            if (!route.didPop(result)) {
              return false;
            }
    
            // Update the list of pages by setting _selectedBook to null
            _selectedBook = null;
            show404 = false;
            notifyListeners();
    
            return true;
          },
        );
      }
    
      @override
      Future<void> setNewRoutePath(BookRoutePath path) async {
        if (path.isUnknown) {
          _selectedBook = null;
          show404 = true;
          return;
        }
    
        if (path.isDetailsPage) {
          if (path.id < 0 || path.id > books.length - 1) {
            show404 = true;
            return;
          }
    
          _selectedBook = books[path.id];
        } else {
          _selectedBook = null;
        }
    
        show404 = false;
      }
    
      void _handleBookTapped(Book book) {
        _selectedBook = book;
        notifyListeners();
      }
    }
    
    class BookDetailsPage extends Page {
      final Book book;
    
      BookDetailsPage({
        this.book,
      }) : super(key: ValueKey(book));
    
      Route createRoute(BuildContext context) {
        return MaterialPageRoute(
          settings: this,
          builder: (BuildContext context) {
            return BookDetailsScreen(book: book);
          },
        );
      }
    }
    
    class BookRoutePath {
      final int id;
      final bool isUnknown;
    
      BookRoutePath.home()
          : id = null,
            isUnknown = false;
    
      BookRoutePath.details(this.id) : isUnknown = false;
    
      BookRoutePath.unknown()
          : id = null,
            isUnknown = true;
    
      bool get isHomePage => id == null;
    
      bool get isDetailsPage => id != null;
    }
    
    class BooksListScreen extends StatelessWidget {
      final List<Book> books;
      final ValueChanged<Book> onTapped;
    
      BooksListScreen({
        @required this.books,
        @required this.onTapped,
      });
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(),
          body: ListView(
            children: [
              for (var book in books)
                ListTile(
                  title: Text(book.title),
                  subtitle: Text(book.author),
                  onTap: () => onTapped(book),
                )
            ],
          ),
        );
      }
    }
    
    class BookDetailsScreen extends StatelessWidget {
      final Book book;
    
      BookDetailsScreen({
        @required this.book,
      });
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(),
          body: Padding(
            padding: const EdgeInsets.all(8.0),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                if (book != null) ...[
                  Text(book.title, style: Theme.of(context).textTheme.headline6),
                  Text(book.author, style: Theme.of(context).textTheme.subtitle1),
                ],
              ],
            ),
          ),
        );
      }
    }
    
    class UnknownScreen extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(),
          body: Center(
            child: Text('404!'),
          ),
        );
      }
    }
    

    现在,在 Chrome 中运行该示例将显示正在导航的路由,并在手动编辑 URL 后导航到正确的页面。

    TransitionDelegate

    您可以提供 TransitionDelegate 的自定义实现,该实现可自定义当页面列表更改时路由在屏幕上的显示方式(或从中删除)。如果您需要对此进行自定义,请继续阅读,但是如果您对默认行为感到满意,则可以跳过此部分。

    向定义所需行为的 Navigator 提供自定义 TransitionDelegate

    // New:
    TransitionDelegate transitionDelegate = NoAnimationTransitionDelegate();
    
          child: Navigator(
            key: navigatorKey,
            // New:
            transitionDelegate: transitionDelegate,
    

    例如,以下实现禁用所有过渡动画:

    class NoAnimationTransitionDelegate extends TransitionDelegate<void> {
      @override
      Iterable<RouteTransitionRecord> resolve({
        List<RouteTransitionRecord> newPageRouteHistory,
        Map<RouteTransitionRecord, RouteTransitionRecord>
            locationToExitingPageRoute,
        Map<RouteTransitionRecord, List<RouteTransitionRecord>>
            pageRouteToPagelessRoutes,
      }) {
        final results = <RouteTransitionRecord>[];
    
        for (final pageRoute in newPageRouteHistory) {
          if (pageRoute.isWaitingForEnteringDecision) {
            pageRoute.markForAdd();
          }
          results.add(pageRoute);
        }
    
        for (final exitingPageRoute in locationToExitingPageRoute.values) {
          if (exitingPageRoute.isWaitingForExitingDecision) {
            exitingPageRoute.markForRemove();
            final pagelessRoutes = pageRouteToPagelessRoutes[exitingPageRoute];
            if (pagelessRoutes != null) {
              for (final pagelessRoute in pagelessRoutes) {
                pagelessRoute.markForRemove();
              }
            }
          }
    
          results.add(exitingPageRoute);
        }
        return results;
      }
    }
    

    此自定义实现会覆盖 resolve(),后者负责将各种路由标记为推送,弹出,添加,完成或删除:

    • markForPush — 显示带有动画过渡的路由
    • markForAdd — 显示不带动画过渡的路由
    • markForPop — 使用动画过渡删除路由,并以结果完成路由。在此上下文中,“正在完成”表示将结果对象传递到 AppRouterDelegate 上的 onPopPage 回调。
    • markForComplete — 删除没有过渡的路由,并完成 result
    • markForRemove — 删除没有动画过渡且未完成的路由。

    此类仅影响声明性API,这就是为什么后退按钮仍显示过渡动画的原因。

    本示例的工作方式:本示例同时查看新路由和退出屏幕的路由。它遍历 newPageRouteHistory 中的所有对象,并使用 markForAdd 标记它们添加而无需过渡动画。接下来,它遍历 locationToExitingPageRoute 映射的值。如果找到标记为 isWaitingForExitingDecision 的路由,则它将调用 markForRemove 以指示应删除该路由而不进行过渡也不要完成。

    这是完整的例子(Gist)

    嵌套路由

    这个较大的演示演示了如何在另一个 Router 中添加一个 Router。许多应用程序要求在 BottomAppBar 中具有到达目的地的路由,而在其上方的一堆视图需要路由,这需要两个 Navigator。为此,应用程序使用应用程序状态对象来存储特定于应用程序的导航状态(选定的菜单索引和选定的 Book 对象)。此示例还显示了如何配置哪个 Router处理后退按钮。

    嵌套 Router 例子(Gist)

    下一步是什么

    本文探讨了如何针对特定应用程序使用这些 API,但也可以用于构建更高级别的 API 程序包。希望您能与我们一起探索基于这些功能的高级 API 对用户的作用。