[Flutter WEB ✕ Navigation v2] 使用版型 (layout) 与跳转页面

完整专案原始码(Github)

文章脉络与 commit 的程序码变化是一致的,可以对照着看

若文章有任何错误、不合台湾用语,欢迎指正,感谢

final_demo.gif

必备知识

  1. 了解 Flutter 基本知识
  2. 了解 Navigation v2

需求

  1. 根据不同网址,呈现不同页面(使用 Navigation v2 与 Router)
  2. 要有固定的侧边栏(Sidebar),只变更主内容,不影响版型

前言

  1. 本文预计会花半小时以上阅读、实作

0. 介绍

下图为流程

  1. 从浏览器那接收 RouteInformation,该类别含有网址与状态
  2. 透过 RouteInformationParser 解析 RouteInformation,并转换成需要资料结构(在此范例是使用 自订MyRouteConfig
  3. 将转换後的资料结构(MyRouteConfig),传递给 RouterDelegate
  4. RouterDelegate 可以根据 MyRouteConfig 决定路由的内容,并呈现出来

overview.jpg


以下内容从 commit 讯息为 starter 开始

  1. 如果 commit 讯息出现此图示 「?」,代表该 commit 无法编译成功或会有期他问题,请接着往下读
  2. 在各个章节或小节开头会列出该节完成後的 commit 的连结

1. 新增自订的资料结构 MyRouteConfig

commit create my_route_config

  • 新增档案 navigation/my_route_config.dart
  • 此资料结构定义了应用程序会有哪些路径需要对应
  • URI 在这边只需要知道他有储存 path 即可,也就是网址 domain 之後的路径
import 'package:equatable/equatable.dart';

class MyRouteConfig extends Equatable {
  // 1. 储存路径
  final Uri uri;

  // 2. 私有建构子
  // 因为每个路径都会需要 path 的参数,所以写一个共用的建构子
  MyRouteConfig._(String path): uri = Uri(path: path);
  
  // 3. 各个路径设定
  MyRouteConfig.home():    this._('/');
  MyRouteConfig.about():   this._('/about');
  MyRouteConfig.contact(): this._('/contact');
  MyRouteConfig.unknown(): this._('/unknown');
 
  // 4. 覆写 Equatable 的方法
  // 两个 config 在比较的时候,只需要使用 `==` 就可以比较路径来判断相等性
  @override
  List<Object> get props => [ uri.path ];
}

2. 新增 RouteInformationParser

commit create my_route_information_parser

  • 新增档案 navigation/my_route_information_parser.dart
import 'package:flutter/material.dart';

import 'my_route_config.dart';

class MyRouteInformationParser extends RouteInformationParser<MyRouteConfig> {
  
  // 1. 如函式名称,就是解析 RouteInformation(存有网址与状态)
  @override
  Future<MyRouteConfig> parseRouteInformation(
      RouteInformation routeInformation) async {
    final uri = Uri.parse(routeInformation.location);
    final pathSegments = uri.pathSegments;
    final pathSegmentsCount = pathSegments.length;

    // Handle `/`
    if (pathSegmentsCount == 0) {
      return MyRouteConfig.home();
    }

    // 其他路由...

    return MyRouteConfig.unknown();
  }

  // 之後提,先当作没看见
  @override
  RouteInformation restoreRouteInformation(MyRouteConfig routeConfig) {
    return RouteInformation(location: routeConfig.uri.path);
  }

}

  • parseRouteInformation 负责解析路由资讯
  • 解析网址,uri.pathSegments 资料类型为 List<String>,会将网址切开,例如:
    • 网址 /pathSegments[](空串列)
    • 网址 /aboutpathSegments[ 'about' ](长度为 1)
final uri = Uri.parse(routeInformation.location);
final pathSegments = uri.pathSegments;
final pathSegmentsCount = pathSegments.length;

  • RouteInformation 转换成 RouteConfig ,将其回传(後续会传给 RouterDelegate 使用)
// Handle `/`
if (pathSegmentsCount == 0) {
    // 这里代表 `pathSegments` 为空,网址为 `/`
    // 只有 `RouteConfig.home()` 符合,回传此 `RouteConfig`
    return RouteConfig.home(); 
}

补上其他路由後的完整程序码:

import 'package:flutter/material.dart';

import 'my_route_config.dart';

class MyRouteInformationParser extends RouteInformationParser<MyRouteConfig> {
  @override
  Future<MyRouteConfig> parseRouteInformation(
      RouteInformation routeInformation) async {
    final uri = Uri.parse(routeInformation.location);
    final pathSegments = uri.pathSegments;
    final pathSegmentsCount = pathSegments.length;

    // Handle `/`
    if (pathSegmentsCount == 0) {
      return MyRouteConfig.home();
    }

    // Handle `/xxx`
    if (pathSegmentsCount == 1) {
      if (pathSegments[0] == MyRouteConfig.about().uri.pathSegments[0]) {
        return MyRouteConfig.about();
      }

      if (pathSegments[0] == MyRouteConfig.contact().uri.pathSegments[0]) {
        return MyRouteConfig.contact();
      }

      if (pathSegments[0] == MyRouteConfig.unknown().uri.pathSegments[0]) {
        return MyRouteConfig.unknown();
      }
    }

    return MyRouteConfig.unknown();
  }

  @override
  RouteInformation restoreRouteInformation(MyRouteConfig routeConfig) {
    return RouteInformation(location: routeConfig.uri.path);
  }

}

3. 新增 RouterDelegate

commit ? create my_router_delegate

  • 新增档案 navigation/my_router_delegate.dart
  • 新增一个 class MyRouterDelegate 继承自 RouterDelegate<MyRouteConfig>
  • 会有几个方法需要覆写
import 'package:flutter/material.dart';
import 'package:tutorial_flutter_navigation_v2/screens/simple_screen.dart';

import 'my_route_config.dart';

class MyRouterDelegate extends RouterDelegate<MyRouteConfig>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin<MyRouteConfig> {

  @override
  GlobalKey<NavigatorState> get navigatorKey => throw UnimplementedError();

  @override
  MyRouteConfig get currentConfiguration => throw UnimplementedError();

  @override
  Widget build(BuildContext context) {
    throw UnimplementedError();
  }

  @override
  Future<void> setNewRoutePath(MyRouteConfig newState) async {
    throw UnimplementedError();
  }

}

commit ? store current MyRouteConfig

新增 _currentConfiguration 储存目前的 MyRouteConfig

class MyRouterDelegate extends RouterDelegate<MyRouteConfig>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin<MyRouteConfig> {

  // 1. 新增变数:目前的 `MyRouteConfig` 
  MyRouteConfig _currentConfiguration;
    
  // ...

  // 2. 覆写取用 `MyRouteConfig` 的方法
  @override
  MyRouteConfig get currentConfiguration => _currentConfiguration;

  // ...

  // 3. 如果有新的 MyRouteConfig,就更新一下吧
  @override
  Future<void> setNewRoutePath(MyRouteConfig newMyRouteConfig) async {
    _currentConfiguration = newMyRouteConfig;
    return;
  }

}

commit ? build Navigator

建立一个 Navigator 负责处理主画面的路由

class MyRouterDelegate extends RouterDelegate<MyRouteConfig>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin<MyRouteConfig> {

  // 1. 宣告
  final GlobalKey<NavigatorState> _navigatorKey;

  // ...
    
  // 2. 在建构的时候初始化
  MyRouterDelegate(): _navigatorKey = GlobalKey<NavigatorState>();

  // 3. 覆写 `navigatorKey`,让外部取得 `navigatorKey`
  @override
  GlobalKey<NavigatorState> get navigatorKey => _navigatorKey;

  // ...

  @override
  Widget build(BuildContext context) {
    // 4. 建立 Navigator,并将 `key` 传入
    return Navigator(
      key: _navigatorKey,
    );
  }

  // ...

}

commit ? pass onPopPage to Navigator

建立 onPopPage 并传入 Navigator

bool onPopPage(Route<dynamic> route, result) {
    return route.didPop(result);
}

return Navigator(
    key: _navigatorKey,
    onPopPage: onPopPage,
);

commit build pages by current route config

根据现有的 MyRouteConfigs,传入 Navigator 需要的 pages,决定显示哪些页面

class MyRouterDelegate extends RouterDelegate<MyRouteConfig>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin<MyRouteConfig> {

  // ...

  @override
  Widget build(BuildContext context) {
    // ...
    return Navigator(
      key: _navigatorKey,
      pages: _buildPages(), // 3. 传给 Navigator 
      onPopPage: onPopPage,
    );
  }

  // 1. 建立 Pages
  List<Page<dynamic>> _buildPages() {
    final List<Page<dynamic>> pages = [
      MaterialPage(
          key: ValueKey('Home'),
          name: 'Home',
          child: SimpleScreen(text: 'Home')
      ),
    ];

    // 2. 根据不同路径,加入不同 Page
    if (_currentConfiguration == MyRouteConfig.contact())
      pages.add(MaterialPage(
          key: ValueKey('Contact'),
          name: 'Contact',
          child: SimpleScreen(text: 'Contact')
      ));

    if (_currentConfiguration == MyRouteConfig.about())
      pages.add(MaterialPage(
          key: ValueKey('About'),
          name: 'About',
          child: SimpleScreen(text: 'About')
      ));

    if (_currentConfiguration == MyRouteConfig.unknown())
      pages.add(MaterialPage(
          key: ValueKey('Unknown'),
          name: 'Unknown',
          child: SimpleScreen(text: 'Unknown')
      ));

    return pages;
  }

  @override
  Future<void> setNewRoutePath(MyRouteConfig newMyRouteConfig) async {
    _currentConfiguration = newMyRouteConfig;
    return;
  }

}

4. 跳转页面

commit wrap main content with router

刚刚写的 MyRouterDelegateMyRouteInformationParser 拿来使用

因为左侧侧边栏位固定,只需要对主内容进行路由管理

因此,将 Router (为了美观,被 Exapnded 包住)放在 BaseSideBar 旁边

现在应该会是 About 的页面

layout/base_layout.dart

// ...

class _BaseLayoutState extends State<BaseLayout> {

  // 提供初始的路由资讯(initialRouteInformation)
  PlatformRouteInformationProvider _routeInformationProvider;

  MyRouterDelegate _routerDelegate;
  MyRouteInformationParser _routeInformationParser = MyRouteInformationParser();

  @override
  void initState() {
    super.initState();

    _routeInformationProvider = PlatformRouteInformationProvider(
        initialRouteInformation: RouteInformation(
          location: '/about',
        )
    );

    _routerDelegate = MyRouterDelegate();
  }

  @override
  void dispose() {
    _routeInformationProvider.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Row(
        children: [
          BaseSideBar(),
          Expanded(
            child: Router(
              routerDelegate: _routerDelegate,
              routeInformationParser: _routeInformationParser,
              routeInformationProvider: _routeInformationProvider,
            )
          ),
        ],
      ),
    );
  }
}

commit add some jumping page method

新增一些跳转网页的方法

navigation/my_router_delegate.dart

void goHome() {
  _currentConfiguration = MyRouteConfig.home();
  notifyListeners();
}

void goAbout() {
  _currentConfiguration = MyRouteConfig.about();
  notifyListeners();
}

void goContact() {
  _currentConfiguration = MyRouteConfig.contact();
  notifyListeners();
}

commit go about page on simple screen

SimpleScreen 都是在 MyRouterDelegate 中的 Navigatorpages

因此可以透过 context 取得 MyRouterDelegate

screens/simple_screen.dart

TextButton(
  onPressed: () {
    final delegate = Router.of(context).routerDelegate as MyRouterDelegate;
    delegate.goAbout();
  },
  child: Text('About us >'),
),

将初始路径改为 about 以外,再点击按钮,跳转即可成功

(详细看 commit 内容)

5. 透过侧边栏跳转页面

BaseSideBar 没有被 Router 包住,所以没办法透过 context 取得 MyRouterDelegate

因此透过建构子传递 MyRouterDelegateBaseSideBar 使用


commit go to pages by items of sidebar

先将 _routerDelegate 传给 BaseSideBar

layout/base_layout.dart

BaseSideBar(
  myRouterDelegate: _routerDelegate,
),
import 'package:flutter/material.dart';
import 'package:tutorial_flutter_navigation_v2/navigation/my_router_delegate.dart';

class BaseSideBar extends StatefulWidget {
  final MyRouterDelegate myRouterDelegate;

  // 1. 传入 `MyRouterDelegate`
  BaseSideBar({
    @required this.myRouterDelegate
  });

  @override
  _BaseSideBarState createState() => _BaseSideBarState();
}

class _BaseSideBarState extends State<BaseSideBar> {
  MyRouterDelegate _myRouterDelegate;

  @override
  void initState() {
    super.initState();
    _myRouterDelegate = widget.myRouterDelegate;
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      // ...
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          _title(),
          _link(
            text: 'Home',
            onPressed: () {
              _myRouterDelegate.goHome(); // 2. 跳转至首页
            },
          ),
          _link(
            text: 'About',
            onPressed: () {
              _myRouterDelegate.goAbout(); // 2. 跳转至关於页面
            },
          ),
          _link(
            text: 'Contact',
            onPressed: () {
              _myRouterDelegate.goContact(); // 2. 跳转至联络页面
            },
          ),
        ],
      ),
    );
  }
    
  // ...
}

6. 处理网址上的 「#(井字号)」

commit setPathUrlStrategy

现在的网址都是 localhost:xxxx/#/

先把网址变成 localhost:xxxx/ 利於我们後面处理网址

main.dart

import 'package:flutter/material.dart';
import 'package:url_strategy/url_strategy.dart'; // 加上这行

import 'app/app.dart';

void main() {
  setPathUrlStrategy(); // 加上这行

  runApp(MyApp());
}

7. App 无法根据网址路由的问题

当第一次进入页面、或重整,如果网址是 localhost:xxx 那就没问题,

可是如果是 localhost:xxx/yyy 就会有问题

init_route_problem.PNG

因为没有设定 App 的路由系统,

以目前需求来说,不需要在 App 那边做路由

所以让任何路径都回传 BaseLayout 即可

commit allow all paths

app.dart

import 'package:flutter/material.dart';
import 'package:tutorial_flutter_navigation_v2/layout/base_layout.dart';

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Navigation',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      onGenerateRoute: (_) {
        // 不做任何判断,直接回传
        return PageRouteBuilder(
            transitionDuration: const Duration(milliseconds: 0),
            reverseTransitionDuration: const Duration(milliseconds: 0),
            // 都回传 `BaseLayout`
            pageBuilder: (__, ___, ____) => BaseLayout() 
        );
      }
    );
  }
}

commit init location by parsing url

到目前为止,PlatformRouteInformationProviderinitialRouteInformationlocation 初始化都是固定的

透过套件取得网址、解析、再传给 PlatformRouteInformationProvider 即可

layout/base_layout.dart

@override
void initState() {
  super.initState();

  _routeInformationProvider = PlatformRouteInformationProvider(
      initialRouteInformation: RouteInformation(
        location: _getInitialRoute(),
      )
  );

  // ...
}

String _getInitialRoute() {
  var initialRoute = "/";
  if (kIsWeb) {
    final origin = html.window.location.origin;
    final href = html.window.location.href;
    initialRoute = href.substring(origin.length);
  }
  return initialRoute;
}

8. 改善初始页面呈现方式

若使用浏览器的上下页按钮,

会发现首页会出现一段时间,然後才被目前的页面盖住

这里采用简单的做法:「给 MyRouterDelegate 初始化的 MyRouteConfig

8-1. 先将「网址转换成 MyRouteConfig」方法放於公用程序(Utility)

commit parsing url to route config: extract to utility

为了後续程序码整洁

MyRouteInformationParser 中的 parseRouteInformation 程序码抽出

放於 utilities/route_utility.dart

import 'package:flutter/material.dart';
import 'package:tutorial_flutter_navigation_v2/navigation/my_route_config.dart';

class RouteUtility {

  RouteUtility._();

  static MyRouteConfig getRouteConfig(RouteInformation routeInformation) {
    final uri = Uri.parse(routeInformation.location);
    final pathSegments = uri.pathSegments;
    final pathSegmentsCount = pathSegments.length;

    // Handle `/`
    if (pathSegmentsCount == 0) {
      return MyRouteConfig.home();
    }

    // Handle `/xxx`
    if (pathSegmentsCount == 1) {
      if (pathSegments[0] == MyRouteConfig.about().uri.pathSegments[0]) {
        return MyRouteConfig.about();
      }

      if (pathSegments[0] == MyRouteConfig.contact().uri.pathSegments[0]) {
        return MyRouteConfig.contact();
      }

      if (pathSegments[0] == MyRouteConfig.unknown().uri.pathSegments[0]) {
        return MyRouteConfig.unknown();
      }
    }

    return MyRouteConfig.unknown();
  }

}

MyRouteInformationParserparseRouteInformation 则使用 RouteUtility.getRouteConfig()

navigation/my_route_information_parser.dart

import 'package:flutter/material.dart';
import 'package:tutorial_flutter_navigation_v2/utilities/route_utility.dart';

import 'my_route_config.dart';

class MyRouteInformationParser extends RouteInformationParser<MyRouteConfig> {
  @override
  Future<MyRouteConfig> parseRouteInformation(
      RouteInformation routeInformation) async {
    return RouteUtility.getRouteConfig(routeInformation);
  }

  @override
  RouteInformation restoreRouteInformation(MyRouteConfig routeConfig) {
    return RouteInformation(location: routeConfig.uri.path);
  }
}

8-2. 初始化 MyRouterDelegate 中的 _currentConfiguration

commit init route config of router delegate

根据初始化的路径,产生对应的 MyRouteConfig ,并传给 MyRouterDelegate,让一开始就已经决定好路径,而不会重新侦测并转场,避免上述的问题

layout/base_layout.dart

class _BaseLayoutState extends State<BaseLayout> {
  // ...

  @override
  void initState() {
    super.initState();
    final routeInformation = RouteInformation(
      location: _getInitialRoute(),
    );

    _routeInformationProvider = PlatformRouteInformationProvider(
        initialRouteInformation: routeInformation
    );

    final routeConfig = RouteUtility.getRouteConfig(routeInformation);
    _routerDelegate = MyRouterDelegate(myRouteConfig: routeConfig);
  }

  // ...
}

修改 MyRouterDelegate 的建构子,使其接收 MyRouteConfig

layout/base_layout.dart

MyRouterDelegate({
  MyRouteConfig myRouteConfig,
}): _currentConfiguration = myRouteConfig,
      _navigatorKey = GlobalKey<NavigatorState>();

X. 操控浏览器历史纪录

网页可以储存历史纪录与其状态,使用上一页或下一页的时候状态仍会存在

详情可参考 MDN Web Docs: 操控浏览器历史纪录

待更新...

下一步...

如果上面的成果仍不满意,可以尝试从以下角度继续完善专案

  • 制作成响应式(RWD),可以使用此套件 responsive_builder 、Widget LayoutBuilder 或自己制作一些特别的 Widget 或机制
  • 页面转场效果

若文章有任何错误、不合台湾用语,欢迎指正,感谢您的阅读

关於我

我的 Github Page

喜欢 Flutter ❤️

参考资料


<<:  系统分析师的养成之路—案例分享(4)

>>:  [Day 40] 心情随笔後台及前台(二) - 新增心情随笔资料

Elastic Stack第二十九重

Logstash 本篇介绍何谓Logstash以及他的功用,并从安装到使用基本的pipeline L...

数据操作语言(Data manipulation language)

这个问题描述了常见的SQL注入场景,该场景采用了像1 = 1这样的所谓“身份方程序”。攻击者可以输入...

[Day17] - Django-REST-Framework 第一个 API 实作

了解了 Django 的运作之後,相信大家一步步对 Django 的操作更佳的熟悉,在前面我们介绍了...

Kotlin Android 第15天,从 0 到 ML - Android Jetpack

前言: 前两遍的基础activity 和 fragment 就可以作出不错的app了,但功能愈来愈多...

Day28 测试写起乃 - Timecop

Timecop 可以帮助你在测试时将时间冻住,因为有些讯息中会带有时间,如果要确保时间一致就必须要将...