[譯] MDC-104 Flutter:Material 高階元件(Flutter)

DevMcryYu發表於2019-03-26

1. 介紹

Material 元件(MDC)幫助開發者實現 Material Design。MDC 由谷歌團隊的工程師和 UX 設計師創造,為 Android、iOS、Web 和 Flutter 提供很多美觀實用的 UI 元件。

在 MDC-103 教程中,自定義定製了 Material 元件(MDC)的顏色、高度、排版和形狀來給你的應用設定樣式。

Material Design 系統中的元件執行一些預定義的工作並具有一定特徵,例如一個 button。然而一個 button 不僅僅是用來給使用者執行操作的,它可以用其形狀、尺寸和顏色表達一種視覺體驗,讓使用者知道它是可互動的,觸控或點選它時可能會有事情發生。

Material Design 指南以設計師的角度來描述元件。它們描述了跨平臺可用的基本功能以及構成每個元件的基本元素。例如,一個背景包含一個背層內容、前層內容及其本身的內容、運動規則和顯示選項。根據每個應用的需求、用例和內容可以自定義每個元件,包括傳統的檢視、控制元件以及你所處平臺 SDK 的功能。

Material Design 指南命名了很多元件,但不是所有的元件都可以很好的被重用,因此無法在 MDC 中找到它們。你可以自己塑造這樣的經歷,實現使用傳統程式碼自定義你的應用樣式。

你將構建一個

本教程裡,將把 Shrine 應用的 UI 修改成名為“背景”的兩級展示。它包含一個選單,列出了用於過濾在不對稱網格中展示的產品的可選類別。在本教程中,你將使用如下 Flutter 元件:

  • 形狀(Shape)
  • 動作(Motion)
  • Flutter 小部件(在往期教程中所使用的)

[譯] MDC-104 Flutter:Material 高階元件(Flutter)

[譯] MDC-104 Flutter:Material 高階元件(Flutter)

這是四篇教程中的最後一篇,它將指導你構建一個名為 Shrine 的應用。我們建議你閱讀每篇教程,跟隨進度逐步完成此專案。

有關教程可以在這裡找到:

此教程中的 MDC-Flutter 元件

  • 形狀(Shape)

你將需要

  • Flutter SDK
  • 安裝好 Flutter 外掛的 Android Studio,或者你喜歡的程式碼編輯器
  • 示例程式碼

要在 iOS 上構建和執行 Flutter 應用程式,你需要滿足以下要求:

  • 執行 macOS 的計算機
  • Xcode 9 或更新版本
  • iOS 模擬器,或者 iOS 物理裝置

要在 Android 上構建和執行 Flutter 應用程式,你需要滿足以下要求:

  • 執行 macOS、Windows 或 Linux 的計算機
  • Android Studio
  • Android 模擬器(隨 Android Studio 一起提供)或 Android 物理裝置

2. 安裝 Flutter 環境

前提條件

要開始使用 Flutter 開發移動應用程式,你需要:

  • Flutter SDK
  • 裝有 Flutter 外掛的 IntelliJ IDE,或者你喜歡的程式碼編輯器

Flutter 的 IDE 工具適用於 Android StudioIntelliJ IDEA Community(免費)和 IntelliJ IDEA Ultimate

要在 iOS 上構建和執行 Flutter 應用程式,你需要滿足以下要求:

  • 執行 macOS 的計算機
  • Xcode 9 或更新版本
  • iOS 模擬器,或者 iOS 物理裝置

要在 Android 上構建和執行 Flutter 應用程式,你需要滿足以下要求:

  • 執行 macOS,Windows 或者 Linux 的計算機
  • Android Studio
  • Android 模擬器(隨 Android Studio 一起提供)或 Android 物理裝置

獲取詳細的 Flutter 安裝資訊

重要提示:如果連線到計算機的 Android 手機上出現“允許 USB 除錯”對話方塊,請啟用始終允許從此計算機選項,然後單擊確定

在繼續本教程之前,請確保你的 SDK 處於正確的狀態。如果之前安裝過 Flutter SDK,則使用 flutter upgrade 來確保 SDK 處於最新版本。

flutter upgrade
複製程式碼

執行 flutter upgrade 將自動執行 flutter doctor。如果這是首次安裝 Flutter 且不需升級,那麼請手動執行 flutter doctor。檢視顯示的所有 ✓ 標記;這將會下載你需要的任何缺少的 SDK 檔案,並確保你的計算機配置無誤以進行 Flutter 的開發。

flutter doctor
複製程式碼

3. 下載教程初始應用程式

從 MDC-103 繼續?

如果你完成了 MDC-103,那麼本教程所需的程式碼應該已經準備就緒。跳轉到:新增背景選單

從頭開始?

下載入門程式

初始程式位於 material-components-flutter-codelabs-104-starter_and_103-complete/mdc_100_series 目錄下。

...或者從 GitHub 克隆它

從 GitHub 克隆此專案,執行以下命令:

git clone https://github.com/material-components/material-components-flutter-codelabs.git
cd material-components-flutter-codelabs
git checkout 104-starter\_and\_103-complete
複製程式碼

更多幫助:從 GitHub 克隆一個倉庫

正確的分支

教程 MDC-101 到 MDC-104 在前一個基礎上持續構建。MDC-103 的完整程式碼將是 MDC-104 的初始程式碼。程式碼被分成多個分支。要列出 GitHub 中的分支,使用如下命令:

git branch --list

想要檢視完整程式碼,切換到 104-complete 分支。

建立你的專案

以下步驟預設你使用的是 Android Studio (IntelliJ)。

建立專案

  1. 在終端中,導航到 material-components-flutter-codelabs

  2. 執行 flutter create mdc_100_series

[譯] MDC-104 Flutter:Material 高階元件(Flutter)

開啟專案

  1. 開啟 Android Studio。

  2. 如果你看到歡迎頁面,單擊開啟已有的 Android Studio 專案

[譯] MDC-104 Flutter:Material 高階元件(Flutter)

  1. 導航到 material-components-flutter-codelabs/mdc_100_series 目錄並單擊開啟,這將開啟此專案。

在構建專案一次之前,你可以忽略在分析中見到的任何錯誤。

[譯] MDC-104 Flutter:Material 高階元件(Flutter)

  1. 在左側的專案皮膚中,如果看到測試檔案 ../test/widget_test.dart,刪除它。

[譯] MDC-104 Flutter:Material 高階元件(Flutter)

  1. 如果出現上圖提示,安裝所有平臺和外掛更新或 FlutterRunConfigurationType,然後重新啟動 Android Studio。

[譯] MDC-104 Flutter:Material 高階元件(Flutter)

提示:確保你已安裝 Flutter 和 Dart 外掛

執行初始程式

以下步驟預設你在 Android 模擬器或真實裝置上進行測試。如果你安裝了 Xcode,則也可以在 iOS 模擬器或裝置上測試。

  1. 選擇裝置或模擬器

如果 Andorid 模擬器尚未執行,選擇 Tools -> Android -> AVD Manager建立並執行一個模擬裝置。如果 AVD 已存在,你可以直接在 IntelliJ 的裝置選擇器中啟動模擬器,如下一步所示。

(對於 iOS 模擬器,如果它尚未執行,通過選擇 Flutter Device Selection -> Open iOS Simulator 來在你的開發裝置上啟動它。)

[譯] MDC-104 Flutter:Material 高階元件(Flutter)

  1. 啟動 Flutter 應用:
  • 在你的編輯器視窗頂部尋找 Flutter Device Selection 下拉選單,然後選擇裝置(例如,iPhone SE / Android SDK built for <version>)。
  • 點選執行圖示(
    [譯] MDC-104 Flutter:Material 高階元件(Flutter)
    )。

[譯] MDC-104 Flutter:Material 高階元件(Flutter)

如果你無法成功執行此應用程式,停下來解決你的開發環境問題。嘗試導航到 material-components-flutter-codelabs;如果你在終端中下載 .zip 檔案,導航到 material-components-flutter-codelabs-... 然後執行 flutter create mdc_100_series

成功!上一篇教程中 Shrine 的登陸頁面應該在你的模擬器中執行了。你可以看到 Shrine 的 logo 和它下面的名稱 "Shrine"。

[譯] MDC-104 Flutter:Material 高階元件(Flutter)

如果應用沒有更新,再次單擊 “Play” 按鈕,或者點選 “Play” 後的 “Stop”。

4. 新增背景選單

背景出現在所有其他內容和元件後面。它由兩層組成:後層(顯示操作和過濾器)和前層(用來顯示內容)。你可以使用背景來顯示互動資訊和操作,例如導航或內容過濾。

移除 home 的應用欄

HomePage 的小部件將成為前層的內容。現在它有一個應用欄。我們將應用欄移動到後層,這樣 HomePage 將只包含 AsymmetricView。

home.dart中,修改 build() 方法使其僅返回一個 AsymmetricView:

// TODO:返回一個 AsymmetricView(104)
return  AsymmetricView(products: ProductsRepository.loadProducts(Category.all));
複製程式碼

新增背景小部件

建立名為 Backdrop 的小部件,使其包含 frontLayerbackLayer

backLayer 包含一個選單,它允許你選擇一個類別來過濾列表(currentCategory)。由於我們希望選單選擇保持不變,因此我們將 Backdrop 繼承 StatefulWidget。

/lib 下新增名為 backdrop.dart 的檔案:

    import 'package:flutter/material.dart';
    import 'package:meta/meta.dart';

    import 'model/product.dart';

    // TODO:新增速度常量(104)

    class Backdrop extends StatefulWidget {
      final Category currentCategory;
      final Widget frontLayer;
      final Widget backLayer;
      final Widget frontTitle;
      final Widget backTitle;

      const Backdrop({
        @required this.currentCategory,
        @required this.frontLayer,
        @required this.backLayer,
        @required this.frontTitle,
        @required this.backTitle,
      })  : assert(currentCategory != null),
            assert(frontLayer != null),
            assert(backLayer != null),
            assert(frontTitle != null),
            assert(backTitle != null);

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

    // TODO:新增 _FrontLayer 類(104)
    // TODO:新增 _BackdropTitle 類(104)
    // TODO:新增 _BackdropState 類(104)
複製程式碼

匯入 meta 包來新增 @required 標記。當建構函式中的屬性沒有預設值且不能為空的時候,用它來提醒你不能遺漏。注意,我們在構造方法後再一次宣告瞭傳入的值的確不是 null

在 Backdrop 類定義下新增 _BackdropState 類:

    // TODO:新增 _BackdropState 類(104)
    class _BackdropState extends State<Backdrop>
        with SingleTickerProviderStateMixin {
      final GlobalKey _backdropKey = GlobalKey(debugLabel: 'Backdrop');

      // TODO:新增 AnimationController 部件(104)

      // TODO:為 _buildStack 新增 BuildContext 和 BoxConstraints 引數(104)
      Widget _buildStack() {
        return Stack(
        key: _backdropKey,
          children: <Widget>[
            widget.backLayer,
            widget.frontLayer,
          ],
        );
      }

      @override
      Widget build(BuildContext context) {
        var appBar = AppBar(
          brightness: Brightness.light,
          elevation: 0.0,
          titleSpacing: 0.0,
          // TODO:用 IconButton 替換 leading 選單圖示(104)
          // TODO:移除 leading 屬性(104)
          // TODO:使用 _BackdropTitle 引數建立標題(104)
          leading: Icon(Icons.menu),
          title: Text('SHRINE'),
          actions: <Widget>[
            // TODO:新增從尾部圖示到登陸頁面的快捷方式(104)
            IconButton(
              icon: Icon(
                Icons.search,
                semanticLabel: 'search',
              ),
              onPressed: () {
              // TODO:開啟登入(104)
              },
            ),
            IconButton(
              icon: Icon(
                Icons.tune,
                semanticLabel: 'filter',
              ),
              onPressed: () {
              // TODO:開啟登入(104)
              },
            ),
          ],
        );
        return Scaffold(
          appBar: appBar,
          // TODO:返回一個 LayoutBuilder 部件(104)
          body: _buildStack(),
        );
      }
    }
複製程式碼

build() 方法像 HomePage 一樣返回一個帶有 app bar 的 Scaffold。但是 Scaffold 的主體是一個 Stack。Stack 的孩子可以重疊。每個孩子的大小和位置都是相對於 Stack 的父級指定的。

現在在 ShrineApp 中新增一個 Backdrop 例項。

app.dart 中引入 backdrop.dartmodel/product.dart:

    import 'backdrop.dart'; // 新增程式碼
    import 'colors.dart';
    import 'home.dart';
    import 'login.dart';
    import 'model/product.dart'; // 新增程式碼
    import 'supplemental/cut_corners_border.dart';
複製程式碼

app.dart 中修改 ShrineApp 的 build() 方法。將 home: 改成以 HomePage 為 frontLayer 的 Backdrop。

        // TODO:將 home: 改為使用 HomePage frontLayer 的 Backdrop(104)
        home: Backdrop(
          // TODO:使 currentCategory 持有 _currentCategory (104)
          currentCategory: Category.all,
          // TODO:為 frontLayer 傳遞 _currentCategory(104)
          frontLayer: HomePage(),
          // TODO:將 backLayer 的值改為 CategoryMenuPage(104)
          backLayer: Container(color: kShrinePink100),
          frontTitle: Text('SHRINE'),
          backTitle: Text('MENU'),
        ),
複製程式碼

如果你點選執行按鈕,你將會看到主頁與應用欄已經出現了:

[譯] MDC-104 Flutter:Material 高階元件(Flutter)

backLayer 在 frontLayer 的主頁後面插入了一個新的粉色背景。

你可以使用 Flutter Inspector 來驗證在 Stack 裡的主頁後面確實有一個容器。就像這樣:

[譯] MDC-104 Flutter:Material 高階元件(Flutter)

現在你可以調整兩個層的設計和內容。

5. 新增形狀(Shape)

在本小節,你將為 frontLayer 設定樣式以在其左上角新增一個切片。

Material Design 將此類定製稱為形狀。Material 表面可以具有任意形狀。形狀為表面增加了重點和風格,可用於表達品牌特點。普通的矩形形狀可以定製使其具有彎曲或成角度的角和邊緣,以及任意數量的邊。它們可以是對稱的或不規則的。

為 front layer 新增一個形狀(Shape)

斜角 Shrine logo 激發了 Shrine 應用的形狀故事。形狀故事是應用程式中應用的形狀的常見用法。例如,徽標形狀在應用了形狀的登入頁面元素中回顯。在本小節,您將在左上角使用傾斜切片做為前層設定樣式。

backdrop.dart 中,新增新的 _FrontLayer 類:

    // TODO:新增 _FrontLayer 類(104)
    class _FrontLayer extends StatelessWidget {
      // TODO:新增 on-tap 回撥(104)
      const _FrontLayer({
        Key key,
        this.child,
      }) : super(key: key);

      final Widget child;

      @override
      Widget build(BuildContext context) {
        return Material(
          elevation: 16.0,
          shape: BeveledRectangleBorder(
            borderRadius: BorderRadius.only(topLeft: Radius.circular(46.0)),
          ),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: <Widget>[
              // TODO:新增 GestureDetector(104)
              Expanded(
                child: child,
              ),
            ],
          ),
        );
      }
    }
複製程式碼

然後在 BackdropState 的 _buildStack() 方法裡將 front layer 包裹在 _FrontLayer 內:

      Widget _buildStack() {
        // TODO:建立一個 RelativeRectTween 動畫(104)

        return Stack(
        key: _backdropKey,
          children: <Widget>[
            widget.backLayer,
            // TODO:新增 PositionedTransition(104)
            // TODO:在 _FrontLayer 中包裹 front layer(104)
              _FrontLayer(child: widget.frontLayer),
          ],
        );
      }
複製程式碼

過載。

[譯] MDC-104 Flutter:Material 高階元件(Flutter)

我們給 Shrine 的主表面定製了一個形狀。由於表面具有高度,使用者可以看到白色前層後面有東西。讓我們新增一個動作,以便使用者可以看到背景的背景層。

6. 新增動作(Motion)

動作是一種可以讓你的應用變得更真實的方式。它可以是大且誇張的、小且微妙的,亦或是介於兩者之間的。但需要注意的是動作的形式一定要適合使用場景。多次重複的有規律的動作要精細小巧,才不會分散使用者的注意力或佔用太多時間。適當的情況,如使用者第一次開啟應用時,長時的動作可能會更引人注目,一些動畫也可以幫助使用者瞭解如何使用您的應用程式。

為選單按鈕新增顯示動作

backdrop.dart 的頂部,其他類函式外,新增一個常量來表示我們需要的動畫執行的速度:

    // TODO:新增速度常數(104)
    const double _kFlingVelocity = 2.0;
複製程式碼

_BackdropState 中新增 AnimationController 部件,在 initState() 函式中例項化它,並將其部署在 state 的 dispose() 函式中:

      // TODO:新增 AnimationController 部件(104)
      AnimationController _controller;

      @override
      void initState() {
        super.initState();
        _controller = AnimationController(
          duration: Duration(milliseconds: 300),
          value: 1.0,
          vsync: this,
        );
      }

      // TODO:重寫 didUpdateWidget(104)

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

      // TODO:新增函式以確定並改變 front layer 可見性(104)
複製程式碼

部件生命週期

僅在部件成為其渲染樹的一部分之前會呼叫一次 initState() 方法。只有在部件從樹中移除時才會呼叫一次 dispose() 方法。

AnimationController 用來配合 Animation,並提供播放、反向和停止動畫的 API。現在我們需要使用某個方法來移動它。

新增函式以確定並改變 front layer 的可見性:

      // TODO:新增函式以確定並改變 front layer 的可見性(104)
      bool get _frontLayerVisible {
        final AnimationStatus status = _controller.status;
        return status == AnimationStatus.completed ||
            status == AnimationStatus.forward;
      }

      void _toggleBackdropLayerVisibility() {
        _controller.fling(
            velocity: _frontLayerVisible ? -_kFlingVelocity : _kFlingVelocity);
      }
複製程式碼

將 backLayer 包裹在 ExcludeSemantics 部件中。當 back layer 不可見時,此部件將從語義樹中剔除 backLayer 的選單項。

        return Stack(
          key: _backdropKey,
          children: <Widget>[
            // TODO:將 backLayer 包裹在 ExcludeSemantics 部件中(104)
            ExcludeSemantics(
              child: widget.backLayer,
              excluding: _frontLayerVisible,
            ),
          ...
複製程式碼

修改 _buildStack() 方法使其持有一個 BuildContext 和 BoxConstraints。同時包含一個使用 RelativeRectTween 動畫的 PositionedTransition:

      // TODO:為 _buildStack 新增 BuildContext 和 BoxConstraints 引數(104)
      Widget _buildStack(BuildContext context, BoxConstraints constraints) {
        const double layerTitleHeight = 48.0;
        final Size layerSize = constraints.biggest;
        final double layerTop = layerSize.height - layerTitleHeight;

        // TODO:建立一個 RelativeRectTween 動畫(104)
        Animation<RelativeRect> layerAnimation = RelativeRectTween(
          begin: RelativeRect.fromLTRB(
              0.0, layerTop, 0.0, layerTop - layerSize.height),
          end: RelativeRect.fromLTRB(0.0, 0.0, 0.0, 0.0),
        ).animate(_controller.view);

        return Stack(
          key: _backdropKey,
          children: <Widget>[
            ExcludeSemantics(
              child: widget.backLayer,
              excluding: _frontLayerVisible,
            ),
            // TODO:新增一個 PositionedTransition(104)
            PositionedTransition(
              rect: layerAnimation,
              child: _FrontLayer(
                // TODO:在 _BackdropState 上實現 onTap 屬性(104)
                child: widget.frontLayer,
              ),
            ),
          ],
        );
      }
複製程式碼

最後,返回一個使用 _buildStack 作為其 builder 的 LayoutBuilder 部件,而不是為 Scaffold 的主體呼叫 _buildStack 函式:

        return Scaffold(
          appBar: appBar,
          // TODO:返回一個 LayoutBuilder 部件(104)
          body: LayoutBuilder(builder: _buildStack),
        );
複製程式碼

我們使用 LayoutBuilder 將 front/back 堆疊的構建延遲到佈局階段,以便我們可以合併背景的實際整體高度。LayoutBuilder 是一個特殊的部件,其構建器回撥提供了大小約束。

LayoutBuilder

部件樹通過遍歷葉結點來組織布局。約束在樹下傳遞,但是在葉結點根據約束返回其大小之前通常不會計算大小。葉子點無法知道它的父母的大小,因為它尚未計算。

當部件必須知道其父部件的大小以便自行佈局(且父部件大小不依賴於子部件)時,LayoutBuilder 就派上用場了。它使用一個方法來返回部件。

瞭解有關更多資訊,請檢視 LayoutBuilder 類文件。

build() 方法中,將應用欄中的前導選單圖示轉換為 IconButton,並在點選按鈕時使用它來切換 front layer 的可見性。

          // TODO:用 IconButton 替換 leading 選單圖示(104)
          leading: IconButton(
            icon: Icon(Icons.menu),
            onPressed: _toggleBackdropLayerVisibility,
          ),
複製程式碼

在模擬器中過載並點選選單按鈕。

[譯] MDC-104 Flutter:Material 高階元件(Flutter)

front layer 在向下移動(滑動)。但如果向下看,則會出現紅色錯誤和溢位錯誤。這是因為 AsymmetricView 被這個動畫擠壓並變小,反過來使得 Column 的空間更小。最終,Column 不能用給定的空間自行排列並導致錯誤。如果我們用 ListView 替換 Column,則移動時列的尺寸仍然保持不變。

在 ListView 中包裹產品列項

supplemental/product_columns.dart 中,將 OneProductCardColumn 的 Column 替換成 ListView:

    class OneProductCardColumn extends StatelessWidget {
      OneProductCardColumn({this.product});

      final Product product;

      @override
      Widget build(BuildContext context) {
        // TODO:用 ListView 替換 Column(104)
        return ListView(
          reverse: true,
          children: <Widget>[
            SizedBox(
              height: 40.0,
            ),
            ProductCard(
              product: product,
            ),
          ],
        );
      }
    }
複製程式碼

Column 包含 MainAxisAlignment.end。要使得從底部開始佈局,使用 reverse: true。其孩子的順序將翻轉以彌補變化。

過載並點選選單按鈕。

[譯] MDC-104 Flutter:Material 高階元件(Flutter)

OneProductCardColumn 上的灰色溢位警告消失了!現在讓我們修復另一個問題。

supplemental/product_columns.dart 中修改 imageAspectRatio 的計算方式,並將 TwoProductCardColumn 中的 Column 替換成 ListView:

          // TODO:修改 imageAspectRatio 的計算方式(104)
          double imageAspectRatio =
              (heightOfImages >= 0.0 && constraints.biggest.width > heightOfImages)
                  ? constraints.biggest.width / heightOfImages
                  : 33 / 49;

          // TODO:用 ListView 替換 Column(104)
          return ListView(
            children: <Widget>[
              Padding(
                padding: EdgeInsetsDirectional.only(start: 28.0),
                child: top != null
                    ? ProductCard(
                        imageAspectRatio: imageAspectRatio,
                        product: top,
                      )
                    : SizedBox(
                        height: heightOfCards,
                      ),
              ),
              SizedBox(height: spacerHeight),
              Padding(
                padding: EdgeInsetsDirectional.only(end: 28.0),
                child: ProductCard(
                  imageAspectRatio: imageAspectRatio,
                  product: bottom,
                ),
              ),
            ],
          );
        });
複製程式碼

我們還為 imageAspectRatio 新增了一些安全性。

過載。然後點選選單按鈕。

現在已經沒有溢位了。

7. 在 back layer 上新增選單

選單是由可點選文字項組成的列表,當發生點選事件時通知監聽器。在此小節,你將新增一個類別過濾選單。

新增選單

在 front layer 新增選單並在 back layer 新增互動按鈕。

建立名為 lib/category_menu_page.dart 的新檔案:

    import 'package:flutter/material.dart';
    import 'package:meta/meta.dart';

    import 'colors.dart';
    import 'model/product.dart';

    class CategoryMenuPage extends StatelessWidget {
      final Category currentCategory;
      final ValueChanged<Category> onCategoryTap;
      final List<Category> _categories = Category.values;

      const CategoryMenuPage({
        Key key,
        @required this.currentCategory,
        @required this.onCategoryTap,
      })  : assert(currentCategory != null),
            assert(onCategoryTap != null);

      Widget _buildCategory(Category category, BuildContext context) {
        final categoryString =
            category.toString().replaceAll('Category.', '').toUpperCase();
        final ThemeData theme = Theme.of(context);

        return GestureDetector(
          onTap: () => onCategoryTap(category),
          child: category == currentCategory
            ? Column(
              children: <Widget>[
                SizedBox(height: 16.0),
                Text(
                  categoryString,
                  style: theme.textTheme.body2,
                  textAlign: TextAlign.center,
                ),
                SizedBox(height: 14.0),
                Container(
                  width: 70.0,
                  height: 2.0,
                  color: kShrinePink400,
                ),
              ],
            )
          : Padding(
            padding: EdgeInsets.symmetric(vertical: 16.0),
            child: Text(
              categoryString,
              style: theme.textTheme.body2.copyWith(
                  color: kShrineBrown900.withAlpha(153)
                ),
              textAlign: TextAlign.center,
            ),
          ),
        );
      }

      @override
      Widget build(BuildContext context) {
        return Center(
          child: Container(
            padding: EdgeInsets.only(top: 40.0),
            color: kShrinePink100,
            child: ListView(
              children: _categories
                .map((Category c) => _buildCategory(c, context))
                .toList()),
          ),
        );
      }
    }
複製程式碼

它是一個 GestureDetector,它包含一個 Column,其孩子是類別名稱。下劃線用於指示所選的類別。

app.dart 中,將 ShrineApp 部件從 stateless 轉換成 stateful。

  1. 高亮 ShrineApp.
  2. 按 alt(option)+ enter
  3. 選擇 "Convert to StatefulWidget"。
  4. 將 ShrineAppState 類更改為 private(_ShrineAppState)。要從 IDE 主選單執行此操作,請選擇 Refactor > Rename。或者在程式碼中,您可以高亮顯示類名 ShrineAppState,然後右鍵單擊並選擇 Refactor > Rename。輸入 _ShrineAppState 以使該類成為私有。

app.dart 中,為選擇的類別新增一個變數 _ShrineAppState,並在點選時新增一個回撥:

    // TODO:將 ShrineApp 轉換成 stateful 部件(104)
    class _ShrineAppState extends State<ShrineApp> {
      Category _currentCategory = Category.all;

      void _onCategoryTap(Category category) {
        setState(() {
          _currentCategory = category;
        });
      }
複製程式碼

然後將 back layer 修改為 CategoryMenuPage。

app.dart 中引入 CategoryMenuPage:

    import 'backdrop.dart';
    import 'colors.dart';
    import 'home.dart';
    import 'login.dart';
    import 'category_menu_page.dart';
    import 'model/product.dart';
    import 'supplemental/cut_corners_border.dart';
複製程式碼

build() 方法,將 backlayer 欄位修改成 CategoryMenuPage 並讓 currentCategory 欄位持有例項變數。

          home: Backdrop(
            // TODO:讓 currentCategory 欄位持有 _currentCategory(104)
            currentCategory: _currentCategory,
            // TODO:為 frontLayer 傳遞 _currentCategory(104)
            frontLayer: HomePage(),
            // TODO:將 backLayer 修改成 CategoryMenuPage(104)
            backLayer: CategoryMenuPage(
              currentCategory: _currentCategory,
              onCategoryTap: _onCategoryTap,
            ),
            frontTitle: Text('SHRINE'),
            backTitle: Text('MENU'),
          ),
複製程式碼

過載並點選選單按鈕。

[譯] MDC-104 Flutter:Material 高階元件(Flutter)

你點選了選單選項,然而什麼也沒有發生...讓我們修復它。

home.dart 中,為 Category 新增一個變數並將其傳遞給 AsymmetricView。

    import 'package:flutter/material.dart';

    import 'model/products_repository.dart';
    import 'model/product.dart';
    import 'supplemental/asymmetric_view.dart';

    class HomePage extends StatelessWidget {
      // TODO:為 Category 新增一個變數(104)
      final Category category;

      const HomePage({this.category: Category.all});

      @override
      Widget build(BuildContext context) {
        // TODO:為 Category 新增一個變數並將其傳遞給 AsymmetricView(104)
        return AsymmetricView(products: ProductsRepository.loadProducts(category));
      }
    }
複製程式碼

app.dart 中為 frontLayer 傳遞 _currentCategory

            // TODO:為 frontLayer 傳遞 _currentCategory(104)
            frontLayer: HomePage(category: _currentCategory),
複製程式碼

過載。點選模擬器中的選單按鈕並選擇一個類別。

[譯] MDC-104 Flutter:Material 高階元件(Flutter)

點選選單圖示以檢視產品。他們被過濾了!

選擇選單項後關閉 front layer

backdrop.dart 中,為 BackdropState 重寫 didUpdateWidget() 方法:

      // TODO:為 didUpdateWidget() 新增重寫方法(104)
      @override
      void didUpdateWidget(Backdrop old) {
        super.didUpdateWidget(old);

        if (widget.currentCategory != old.currentCategory) {
          _toggleBackdropLayerVisibility();
        } else if (!_frontLayerVisible) {
          _controller.fling(velocity: _kFlingVelocity);
        }
      }
複製程式碼

熱過載,然後點選選單圖示並選擇一個類別。選單應該自動關閉,然後你將看到所選擇類別的物品。現在同樣地將這個功能新增到 front layer 。

切換 front layer

backdrop.dart 中,給 backdrop layer 新增一個 on-tap 回撥:

    class _FrontLayer extends StatelessWidget {
      // TODO:新增 on-tap 回撥(104)
      const _FrontLayer({
        Key key,
        this.onTap, // 新增程式碼
        this.child,
      }) : super(key: key);

      final VoidCallback onTap; // 新增程式碼
      final Widget child;
複製程式碼

然後將一個 GestureDetector 新增到 _FrontLayer 的孩子 Column 的子節點中:

          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: <Widget>[
              // TODO:新增一個 GestureDetector(104)
              GestureDetector(
                behavior: HitTestBehavior.opaque,
                onTap: onTap,
                child: Container(
                  height: 40.0,
                  alignment: AlignmentDirectional.centerStart,
                ),
              ),
              Expanded(
                child: child,
              ),
            ],
          ),
複製程式碼

然後在 _buildStack() 方法的 _BackdropState 中實現新的 onTap 屬性:

              PositionedTransition(
                rect: layerAnimation,
                child: _FrontLayer(
                  // TODO:在 _BackdropState 中實現 onTap 屬性(104)
                  onTap: _toggleBackdropLayerVisibility,
                  child: widget.frontLayer,
                ),
              ),
複製程式碼

過載並點選 front layer 的頂部。每次你點選 front layer 頂部時都它應該開啟或者關閉。

8. 新增品牌圖示

品牌肖像也應該延伸到熟悉的圖示。讓我們自定義顯示圖示並將其與我們的標題合併,以獲得獨特的品牌外觀。

修改選單按鈕圖示

[譯] MDC-104 Flutter:Material 高階元件(Flutter)

backdrop.dart 中,新建 _BackdropTitle 類。

    // TODO:新增 _BackdropTitle 類(104)
    class _BackdropTitle extends AnimatedWidget {
      final Function onPress;
      final Widget frontTitle;
      final Widget backTitle;

      const _BackdropTitle({
        Key key,
        Listenable listenable,
        this.onPress,
        @required this.frontTitle,
        @required this.backTitle,
      })  : assert(frontTitle != null),
            assert(backTitle != null),
            super(key: key, listenable: listenable);

      @override
      Widget build(BuildContext context) {
        final Animation<double> animation = this.listenable;

        return DefaultTextStyle(
          style: Theme.of(context).primaryTextTheme.title,
          softWrap: false,
          overflow: TextOverflow.ellipsis,
          child: Row(children: <Widget>[
            // 品牌圖示
            SizedBox(
              width: 72.0,
              child: IconButton(
                padding: EdgeInsets.only(right: 8.0),
                onPressed: this.onPress,
                icon: Stack(children: <Widget>[
                  Opacity(
                    opacity: animation.value,
                    child: ImageIcon(AssetImage('assets/slanted_menu.png')),
                  ),
                  FractionalTranslation(
                    translation: Tween<Offset>(
                      begin: Offset.zero,
                      end: Offset(1.0, 0.0),
                    ).evaluate(animation),
                    child: ImageIcon(AssetImage('assets/diamond.png')),
                  )]),
              ),
            ),
            // 在這裡,我們在 backTitle 和 frontTitle 之間是實現自定義的交叉淡入淡出效果
            // 這使得兩個文字之間能夠平滑過渡。
            Stack(
              children: <Widget>[
                Opacity(
                  opacity: CurvedAnimation(
                    parent: ReverseAnimation(animation),
                    curve: Interval(0.5, 1.0),
                  ).value,
                  child: FractionalTranslation(
                    translation: Tween<Offset>(
                      begin: Offset.zero,
                      end: Offset(0.5, 0.0),
                    ).evaluate(animation),
                    child: backTitle,
                  ),
                ),
                Opacity(
                  opacity: CurvedAnimation(
                    parent: animation,
                    curve: Interval(0.5, 1.0),
                  ).value,
                  child: FractionalTranslation(
                    translation: Tween<Offset>(
                      begin: Offset(-0.25, 0.0),
                      end: Offset.zero,
                    ).evaluate(animation),
                    child: frontTitle,
                  ),
                ),
              ],
            )
          ]),
        );
      }
    }
複製程式碼

_BackdropTitle 是一個自定義部件,它將替換 AppBartitle 引數的 Text 部件。它有一個動畫選單圖示和前後標題之間的動畫過渡。動畫選單圖示將使用新資源。因此必須將對新 slanted_menu.png 的引用新增到 pubspec.yaml中。

    assets:
        - assets/diamond.png
        - assets/slanted_menu.png
        - packages/shrine_images/0-0.jpg
複製程式碼

移除 AppBar builder 中的 leading 屬性。這樣才能在原始 leading 部件的位置顯示自定義品牌圖示。listenable 動畫和品牌圖示的 onPress 處理將傳遞給 _BackdropTitlefrontTitlebackTitle 也會被傳遞,以便將它們顯示在背景標題中。AppBartitle 引數如下所示:

    // TODO:使用 _BackdropTitle 引數建立標題(104)
    title: _BackdropTitle(
      listenable: _controller.view,
      onPress: _toggleBackdropLayerVisibility,
      frontTitle: widget.frontTitle,
      backTitle: widget.backTitle,
    ),
複製程式碼

品牌圖示在 _BackdropTitle 中建立。它包含一組動畫圖示:傾斜的選單和鑽石,它包裹在 IconButton 中,以便可以按下它。然後將 IconButton 包裝在 SizedBox 中,以便為圖示水平運動騰出空間。

Flutter 的 "everything is a widget" 架構允許更改預設 AppBar 的佈局,而無需建立全新的自定義 AppBar 小部件。title 引數最初是一個 Text 部件,可以用更復雜的 _BackdropTitle 替換。由於 _BackdropTitle 還包含自定義圖示,因此它取代了 leading 屬性,現在可以省略。這個簡單的部件替換是在不改變任何其他引數的情況下完成的,例如動作圖示,它們可以繼續執行。

新增返回登入螢幕的快捷方式

backdrop.dart 中,從應用欄中的兩個尾部圖示向登入螢幕新增一個快捷方式:更改圖示的 semanticLabel 以反映其新用途。

            // TODO:新增從尾部圖示到登陸頁面的快捷方式(104)
            IconButton(
              icon: Icon(
                Icons.search,
                semanticLabel: 'login', // 新增程式碼
              ),
              onPressed: () {
                // TODO:開啟登陸(104)
                Navigator.push(
                  context,
                  MaterialPageRoute(builder: (BuildContext context) => LoginPage()),
                );
              },
            ),
            IconButton(
              icon: Icon(
                Icons.tune,
                semanticLabel: 'login', // 新增程式碼
              ),
              onPressed: () {
                // TODO:開啟登入(104)
                Navigator.push(
                  context,
                  MaterialPageRoute(builder: (BuildContext context) => LoginPage()),
                );
              },
            ),
複製程式碼

如果你嘗試過載將收到錯誤訊息。匯入 login.dart 以修復錯誤:

    import 'login.dart';
複製程式碼

過載應用並點選搜尋或調整按鈕以返回登入螢幕。

9. 總結

通過四篇教程,你已經瞭解瞭如何使用 Material 元件來構建表達品牌個性和風格的獨特,優雅的使用者體驗。

完整的 MDC-104 應用可在 104-complete 分支中找到。

您可以使用該分支中的版本測試你的應用。

下一步

MDC-104 到此已經完成。你可以訪問 Flutter Widget 目錄以在 MDC-Flutter 中探索更多元件。

對於進階的目標,嘗試使用 AnimatedIcon 替換品牌圖示。

要了解如何將應用連線到 Firebase 以獲得後端支援,請參閱 Flutter 中的 Firebase

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章