iT邦幫忙

1

[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 ❤️

參考資料


尚未有邦友留言

立即登入留言