[譯] MDC-104 Flutter:Material 高級組件(Flutter)

1. 介紹

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

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

Material Design 系統中的組件執行一些預約義的工做並具備必定特徵,例如一個 button。然而一個 button 不只僅是用來給用戶執行操做的,它能夠用其形狀、尺寸和顏色表達一種視覺體驗,讓用戶知道它是可交互的,觸摸或點擊它時可能會有事情發生。android

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

Material Design 指南命名了不少組件,但不是全部的組件均可以很好的被重用,所以沒法在 MDC 中找到它們。你能夠本身塑造這樣的經歷,實現使用傳統代碼自定義你的應用樣式。git

你將構建一個

本教程裏,將把 Shrine 應用的 UI 修改爲名爲「背景」的兩級展現。它包含一個菜單,列出了用於過濾在不對稱網格中展現的產品的可選類別。在本教程中,你將使用以下 Flutter 組件:github

  • 形狀(Shape)
  • 動做(Motion)
  • 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 開發移動應用程序,你須要:bash

  • 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

打開項目

  1. 打開 Android Studio。

  2. 若是你看到歡迎頁面,單擊打開已有的 Android Studio 項目

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

在構建項目一次以前,你能夠忽略在分析中見到的任何錯誤。

  1. 在左側的項目面板中,若是看到測試文件 ../test/widget_test.dart,刪除它。

  1. 若是出現上圖提示,安裝全部平臺和插件更新或 FlutterRunConfigurationType,而後從新啓動 Android Studio。

提示:確保你已安裝 Flutter 和 Dart 插件

運行初始程序

如下步驟默認你在 Android 模擬器或真實設備上進行測試。若是你安裝了 Xcode,則也能夠在 iOS 模擬器或設備上測試。

  1. 選擇設備或模擬器

若是 Andorid 模擬器還沒有運行,選擇 Tools -> Android -> AVD Manager建立並運行一個模擬設備。若是 AVD 已存在,你能夠直接在 IntelliJ 的設備選擇器中啓動模擬器,以下一步所示。

(對於 iOS 模擬器,若是它還沒有運行,經過選擇 Flutter Device Selection -> Open iOS Simulator 來在你的開發設備上啓動它。)

  1. 啓動 Flutter 應用:
  • 在你的編輯器窗口頂部尋找 Flutter Device Selection 下拉菜單,而後選擇設備(例如,iPhone SE / Android SDK built for <version>)。
  • 點擊運行圖標(
    )。

若是你沒法成功運行此應用程序,停下來解決你的開發環境問題。嘗試導航到 material-components-flutter-codelabs;若是你在終端中下載 .zip 文件,導航到 material-components-flutter-codelabs-... 而後運行 flutter create mdc_100_series

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

若是應用沒有更新,再次單擊 「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'),
        ),
複製代碼

若是你點擊運行按鈕,你將會看到主頁與應用欄已經出現了:

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

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

如今你能夠調整兩個層的設計和內容。

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),
          ],
        );
      }
複製代碼

重載。

咱們給 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,
          ),
複製代碼

在模擬器中重載並點擊菜單按鈕。

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。其孩子的順序將翻轉以彌補變化。

重載並點擊菜單按鈕。

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'),
          ),
複製代碼

重載並點擊菜單按鈕。

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

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),
複製代碼

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

點擊菜單圖標以查看產品。他們被過濾了!

選擇菜單項後關閉 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. 添加品牌圖標

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

修改菜單按鈕圖標

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前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索