MDC-104 Flutter:Material 進階元件

1. 簡介

logo_components_color_2x_web_96dp.png

Material Design 元件 (MDC) 可協助開發人員實作質感設計。MDC 是由 Google 的工程師和使用者體驗設計師團隊所開發,提供數十種美觀實用的 UI 元件,適用於 Android、iOS、網頁和 Flutter。material.io/develop

在程式碼研究室 MDC-103 中,您已自訂 Material Design 元件 (MDC) 的顏色、高度、字體排版和形狀,藉此設定應用程式的樣式。

質感設計系統中的元件會執行一組預先定義的工作,並具有特定特性,例如按鈕。然而,按鈕不只是讓使用者執行動作的方法,同時也是形狀、大小及顏色的視覺呈現方式,可讓使用者瞭解這是有互動性質,輕觸或點擊就會發生。

Material Design 指南會從設計師的角度說明元件。說明各平台可用的基本功能,以及組成每個元件的原子元素。舉例來說,背景含有一個後層及其內容、前層及其內容、動態規則和顯示選項。您可根據每個應用程式的需求、用途和內容,自訂上述每個元件。

建構項目

在本程式碼研究室中,您會將 Shrine 應用程式中的 UI 變更為「背景幕」的雙層簡報。背景包含選單,列出可選取的類別,用於篩選不對稱格狀圖片中顯示的產品。在本程式碼研究室中,您將使用下列資料:

  • 圖案
  • 動作
  • Flutter 小工具 (您在先前程式碼研究室中使用過的)

Android

iOS

粉紅色和棕色主題的電子商務應用程式,頂端有應用程式列,以及可水平捲動的不對稱格狀區域,內含各式各樣的產品

粉紅色和棕色主題的電子商務應用程式,有頂端應用程式列,以及非對稱式、可水平捲動的格線,其中展示著許多產品

菜單列出 4 種類別

菜單列出 4 個類別

本程式碼研究室中的 Material Flutter 元件和子系統

  • 圖案

針對 Flutter 開發經驗,您會給予什麼評價?

初級 中級 適合

2. 設定 Flutter 開發環境

您需要使用兩項軟體:Flutter SDK編輯器

您可以使用下列任一裝置執行程式碼研究室:

  • 將實體 AndroidiOS 裝置接上電腦,並設為開發人員模式。
  • iOS 模擬器 (需要安裝 Xcode 工具)。
  • Android Emulator (需要在 Android Studio 中設定)。
  • 瀏覽器 (偵錯時必須使用 Chrome)。
  • WindowsLinuxmacOS 桌面應用程式形式。您必須在要部署的平台上進行開發。因此,如果您想開發 Windows 電腦應用程式,就必須在 Windows 上進行開發,才能存取適當的建構鏈結。如要進一步瞭解作業系統的特定需求,請參閱 docs.flutter.dev/desktop

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/mdc_100_series
git checkout 104-starter_and_103-complete

開啟專案並執行應用程式

  1. 在您選擇的編輯器中開啟專案。
  2. 按照開始使用:試用編輯器中的「執行應用程式」操作說明,為所選編輯器執行操作。

大功告成!您應該會在裝置上看到先前程式碼研究室的 Shrine 登入頁面。

Android

iOS

Shrine 登入頁面

神社登入頁面

4. 新增背景幕選單

背景會出現在所有其他內容和元件的後面。這個程序包含兩個圖層:後層 (顯示動作和篩選器) 和前端 (顯示內容)。您可以使用背景顯示互動資訊和動作,例如導覽或內容篩選器。

移除主畫面應用程式列

HomePage 小工具會成為我們前端的內容,這個畫面現在多了應用程式列。我們要將應用程式列移至後層,HomePage 只會含有 AsymmetricView。

home.dart 中,將 build() 函式變更為只傳回 AsymmetricView:

// TODO: Return an AsymmetricView (104)
return AsymmetricView(products: ProductsRepository.loadProducts(Category.all));

新增背景小工具

建立名為「Backdrop」的資訊方塊,其中包含 frontLayerbackLayer

backLayer 包含選單,可讓您選取類別來篩選清單 (currentCategory)。由於我們希望選取的選單持續存在,因此會將背景設為有狀態的小工具。

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

import 'package:flutter/material.dart';

import 'model/product.dart';

// TODO: Add velocity constant (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,
    Key? key,
  }) : super(key: key);

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

// TODO: Add _FrontLayer class (104)
// TODO: Add _BackdropTitle class (104)
// TODO: Add _BackdropState class (104)

請注意,我們會標示特定屬性 required。如果屬性的建構函式沒有預設值,因此不能是 null,那麼這是最佳做法。

在 Backdrop 類別定義下方,新增 _BackdropState 類別:

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

  // TODO: Add AnimationController widget (104)

  // TODO: Add BuildContext and BoxConstraints parameters to _buildStack (104)
  Widget _buildStack() {
    return Stack(
    key: _backdropKey,
      children: <Widget>[
        // TODO: Wrap backLayer in an ExcludeSemantics widget (104)
        widget.backLayer,
        widget.frontLayer,
      ],
    );
  }

  @override
  Widget build(BuildContext context) {
    var appBar = AppBar(
      elevation: 0.0,
      titleSpacing: 0.0,
      // TODO: Replace leading menu icon with IconButton (104)
      // TODO: Remove leading property (104)
      // TODO: Create title with _BackdropTitle parameter (104)
      leading: Icon(Icons.menu),
      title: Text('SHRINE'),
      actions: <Widget>[
        // TODO: Add shortcut to login screen from trailing icons (104)
        IconButton(
          icon: Icon(
            Icons.search,
            semanticLabel: 'search',
          ),
          onPressed: () {
          // TODO: Add open login (104)
          },
        ),
        IconButton(
          icon: Icon(
            Icons.tune,
            semanticLabel: 'filter',
          ),
          onPressed: () {
          // TODO: Add open login (104)
          },
        ),
      ],
    );
    return Scaffold(
      appBar: appBar,
      // TODO: Return a LayoutBuilder widget (104)
      body: _buildStack(),
    );
  }
}

build() 函式會傳回 Scaffold,其中包含應用程式列,就像 HomePage 一樣。但是 Scaffold 的主體是堆疊。堆疊的子項可以重疊。每個子項的大小和位置皆會相對於堆疊的父項指定。

現在將 Backdrop 執行個體新增至 ShrineApp。

app.dart 中,匯入 backdrop.dartmodel/product.dart

import 'backdrop.dart'; // New code
import 'colors.dart';
import 'home.dart';
import 'login.dart';
import 'model/product.dart'; // New code
import 'supplemental/cut_corners_border.dart';

app.dart, 中,傳回 HomePage 做為 frontLayerBackdrop,藉此修改 / 路徑:

// TODO: Change to a Backdrop with a HomePage frontLayer (104)
'/': (BuildContext context) => Backdrop(
     // TODO: Make currentCategory field take _currentCategory (104)
     currentCategory: Category.all,
     // TODO: Pass _currentCategory for frontLayer (104)
     frontLayer: HomePage(),
     // TODO: Change backLayer field value to CategoryMenuPage (104)
     backLayer: Container(color: kShrinePink100),
     frontTitle: Text('SHRINE'),
     backTitle: Text('MENU'),
),

儲存專案,您應該會看到首頁已顯示,而應用程式列如下:

Android

iOS

粉色背景的神社產品頁面

粉紅色背景的神社產品頁面

backLayer 會在 frontLayer 首頁後方的新圖層中顯示粉紅色區域。

您可以使用 Flutter Inspector 驗證 Stack 確實在 HomePage 後方有一個容器。內容應類似下方範例:

92ed338a15a074bd.png

您現在可以調整圖層的設計和內容。

5. 新增形狀

在這個步驟中,您將在左上角新增前圖層的樣式。

Material Design 將這類自訂項目稱為形狀。Material 表面可具有任意形狀。形狀可為表面增添強調效果和風格,也可用於宣傳品牌。您可以自訂一般矩形形狀,讓邊角和邊緣呈現弧形或角形,也可以自訂邊數。這些圖案可以對稱或不規則。

在前層加入形狀

有角形的神社標誌激勵了神社應用程式的形狀故事。形狀故事是應用程式套用形狀的常見用途。舉例來說,標誌形狀會在已套用形狀的登入頁面元素中相稱。在這個步驟中,您將在左上角設定側面裁剪樣式。

backdrop.dart 中新增 _FrontLayer 類別:

// TODO: Add _FrontLayer class (104)
class _FrontLayer extends StatelessWidget {
  // TODO: Add on-tap callback (104)
  const _FrontLayer({
    Key? key,
    required this.child,
  }) : super(key: key);

  final Widget child;

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

接著,在 _BackdropState 的 _buildStack() 函式中,將前圖層納入 _FrontLayer 中:

  Widget _buildStack() {
    // TODO: Create a RelativeRectTween Animation (104)

    return Stack(
    key: _backdropKey,
      children: <Widget>[
        // TODO: Wrap backLayer in an ExcludeSemantics widget (104)
        widget.backLayer,
        // TODO: Add a PositionedTransition (104)
        // TODO: Wrap front layer in _FrontLayer (104)
          _FrontLayer(child: widget.frontLayer),
      ],
    );
  }

重新載入。

Android

iOS

設有客製化形狀的神社產品頁面

使用自訂形狀的神社產品頁面

我們已為 Shrine 的主要介面指定自訂形狀。不過,我們希望這個設計在視覺上與應用程式列建立連結。

變更應用程式列顏色

app.dart 中,將 _buildShrineTheme() 函式變更為以下內容:

ThemeData _buildShrineTheme() {
  final ThemeData base = ThemeData.light(useMaterial3: true);
  return base.copyWith(
    colorScheme: base.colorScheme.copyWith(
      primary: kShrinePink100,
      onPrimary: kShrineBrown900,
      secondary: kShrineBrown900,
      error: kShrineErrorRed,
    ),
    textTheme: _buildShrineTextTheme(base.textTheme),
    textSelectionTheme: const TextSelectionThemeData(
      selectionColor: kShrinePink100,
    ),
    appBarTheme: const AppBarTheme(
      foregroundColor: kShrineBrown900,
      backgroundColor: kShrinePink100,
    ),
    inputDecorationTheme: const InputDecorationTheme(
      border: CutCornersBorder(),
      focusedBorder: CutCornersBorder(
        borderSide: BorderSide(
          width: 2.0,
          color: kShrineBrown900,
        ),
      ),
      floatingLabelStyle: TextStyle(
        color: kShrineBrown900,
      ),
    ),
  );
}

熱重新啟動。現在應該會顯示新的彩色應用程式列。

Android

iOS

含有彩色應用程式列的神社產品頁面

含彩色應用程式列的神社產品頁面

基於這項變更,使用者可以看到前端白色圖層後方的項目。請加入動態效果,方便使用者查看背景幕的後層。

6. 新增動態效果

動態效果是讓您的應用程式變得生動活潑的方法。可以是大型、戲劇化、細微或簡約,或介於兩者之間。不過請注意,您採用的動態效果應該適用於情境。套用至重複一般動作的動態效果應簡潔有力,以免動作幹擾使用者或定期花太多時間。不過,有些情況 (例如使用者首次開啟應用程式時) 看起來更吸引人,而一些動畫可以向使用者說明應用程式的使用方法。

在選單按鈕中加入顯示動態效果

backdrop.dart 頂端的任何類別或函式範圍外,加入常數來代表所需動畫的速度:

// TODO: Add velocity constant (104)
const double _kFlingVelocity = 2.0;

AnimationController 小工具新增至 _BackdropState,在 initState() 函式中將其執行個體化,並在狀態的 dispose() 函式中予以處理:

  // TODO: Add AnimationController widget (104)
  late AnimationController _controller;

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

  // TODO: Add override for didUpdateWidget (104)

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

  // TODO: Add functions to get and change front layer visibility (104)

AnimationController 會協調動畫,並提供 API 來播放、倒轉及停止動畫。接下來,我們需要讓它移動的函式。

新增可以決定及變更前層顯示設定的函式:

  // TODO: Add functions to get and change front layer visibility (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 小工具中。當後層無法顯示時,這個小工具會從語意樹狀結構中排除 backLayer 的選單項目。

    return Stack(
      key: _backdropKey,
      children: <Widget>[
        // TODO: Wrap backLayer in an ExcludeSemantics widget (104)
        ExcludeSemantics(
          child: widget.backLayer,
          excluding: _frontLayerVisible,
        ),
      ...

變更 _buildStack() 函式以採用 BuildContext 和 BoxConstraint。此外,請加入採用 RelativeRectTween 動畫的 PositionedTransition:

  // TODO: Add BuildContext and BoxConstraints parameters to _buildStack (104)
  Widget _buildStack(BuildContext context, BoxConstraints constraints) {
    const double layerTitleHeight = 48.0;
    final Size layerSize = constraints.biggest;
    final double layerTop = layerSize.height - layerTitleHeight;

    // TODO: Create a RelativeRectTween Animation (104)
    Animation<RelativeRect> layerAnimation = RelativeRectTween(
      begin: RelativeRect.fromLTRB(
          0.0, layerTop, 0.0, layerTop - layerSize.height),
      end: const RelativeRect.fromLTRB(0.0, 0.0, 0.0, 0.0),
    ).animate(_controller.view);

    return Stack(
      key: _backdropKey,
      children: <Widget>[
        // TODO: Wrap backLayer in an ExcludeSemantics widget (104)
        ExcludeSemantics(
          child: widget.backLayer,
          excluding: _frontLayerVisible,
        ),
        // TODO: Add a PositionedTransition (104)
        PositionedTransition(
          rect: layerAnimation,
          child: _FrontLayer(
            // TODO: Implement onTap property on _BackdropState (104)
            child: widget.frontLayer,
          ),
        ),
      ],
    );
  }

最後,傳回使用 _buildStack 做為建構工具的 LayoutBuilder 小工具,而非針對 Scaffold 內文呼叫 _buildStack 函式:

    return Scaffold(
      appBar: appBar,
      // TODO: Return a LayoutBuilder widget (104)
      body: LayoutBuilder(builder: _buildStack),
    );

我們已將前端/返回圖層堆疊的建構延後到使用 LayoutBuilder 的版面配置時間,以便加入背景的實際整體高度。LayoutBuilder 是一種特殊小工具,它的建構工具回呼會提供大小限制。

build() 函式中,將應用程式列中的前導選單圖示轉換為 IconButton,並在輕觸按鈕時使用該按鈕切換前層的顯示/隱藏狀態。

      // TODO: Replace leading menu icon with IconButton (104)
      leading: IconButton(
        icon: const Icon(Icons.menu),
        onPressed: _toggleBackdropLayerVisibility,
      ),

重新載入,然後輕觸模擬器中的選單按鈕。

Android

iOS

空白神社選單,有兩項錯誤

空白神社選單,有兩項錯誤

前層會以動畫效果 (滑動) 向下移動。但你也可以往下看,就會發生紅色錯誤和溢位錯誤。這是因為 AsymmetricView 會因這項動畫而被擠壓,變得更小,進而讓 Columns 的空間變小。最後,資料欄無法以指定的空間進行版面配置,因此會導致錯誤。如果利用 ListViews 取代 欄,欄的大小應與其動畫一樣。

納入 ListView 中的產品欄

supplemental/product_columns.dart 中,將 OneProductCardColumn 中的資料欄替換為 ListView:

class OneProductCardColumn extends StatelessWidget {
  const OneProductCardColumn({required this.product, Key? key}) : super(key: key);

  final Product product;

  @override
  Widget build(BuildContext context) {
    // TODO: Replace Column with a ListView (104)
    return ListView(
      physics: const ClampingScrollPhysics(),
      reverse: true,
      children: <Widget>[
        ConstrainedBox(
          constraints: const BoxConstraints(
            maxWidth: 550,
          ),
          child: ProductCard(
            product: product,
          ),
        ),
        const SizedBox(
          height: 40.0,
        ),

      ],
    );
  }
}

此欄包含 MainAxisAlignment.end。如要從底部開始版面配置,請標記 reverse: true。子項的順序已撤銷,以彌補變更的不足。

重新載入並輕觸選單按鈕。

Android

iOS

空白神社選單,發生一個錯誤

空白神社選單,發生一個錯誤

OneProductCardColumn 上的灰色溢出警告已消失!接下來,我們來修正另一個問題。

supplemental/product_columns.dart 中,變更 imageAspectRatio 的計算方式,並將 TwoProductCardColumn 中的資料欄替換為 ListView:

      // TODO: Change imageAspectRatio calculation (104)
      double imageAspectRatio = heightOfImages >= 0.0
          ? constraints.biggest.width / heightOfImages
          : 49.0 / 33.0;
      // TODO: Replace Column with a ListView (104)
      return ListView(
        physics: const ClampingScrollPhysics(),
        children: <Widget>[
          Padding(
            padding: const EdgeInsetsDirectional.only(start: 28.0),
            child: top != null
                ? ProductCard(
                    imageAspectRatio: imageAspectRatio,
                    product: top!,
                  )
                : SizedBox(
                    height: heightOfCards,
                  ),
          ),
          const SizedBox(height: spacerHeight),
          Padding(
            padding: const EdgeInsetsDirectional.only(end: 28.0),
            child: ProductCard(
              imageAspectRatio: imageAspectRatio,
              product: bottom,
            ),
          ),
        ],
      );

我們也為 imageAspectRatio 多添一層安全保障。

重新載入。然後輕觸選單按鈕。

Android

iOS

空白的 Shrine 選單

空白的 Shrine 選單

沒有溢位現象。

7. 在背景圖層上新增選單

選單是可點選的文字項目清單,可在使用者觸碰文字項目時通知監聽器。在這個步驟中,您將新增類別篩選選單。

新增選單

將選單新增至前層,並將互動按鈕新增至後層。

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

import 'package:flutter/material.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,
  }) : super(key: key);

  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>[
              const SizedBox(height: 16.0),
              Text(
                categoryString,
                style: theme.textTheme.bodyLarge,
                textAlign: TextAlign.center,
              ),
              const SizedBox(height: 14.0),
              Container(
                width: 70.0,
                height: 2.0,
                color: kShrinePink400,
              ),
            ],
          )
      : Padding(
        padding: const EdgeInsets.symmetric(vertical: 16.0),
        child: Text(
          categoryString,
          style: theme.textTheme.bodyLarge!.copyWith(
              color: kShrineBrown900.withAlpha(153)
            ),
          textAlign: TextAlign.center,
        ),
      ),
    );
  }

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

這是一個 GestureDetector,用來包裝子項為類別名稱的資料欄。底線則表示所選類別。

app.dart 中,將 ShrineApp 小工具從無狀態轉換為有狀態。

  1. 標示ShrineApp.
  2. 根據您的 IDE 顯示程式碼動作:
  3. Android Studio:按下 ⌥Enter (macOS) 或 Alt + Enter
  4. VS 代碼:按下 ⌘. (macOS) 或 Ctrl+。
  5. 選取 [Convert to StatefulWidget]。
  6. 將 ShrineAppState 類別設為私有 (_ShrineAppState)。在 ShrineAppState 上按一下滑鼠右鍵,然後
  7. Android Studio:依序選取「Refactor」>「Rename」
  8. VS Code:選取「Rename Symbol」
  9. 輸入 _ShrineAppState 即可將類別設為私有。

app.dart 中,為所選類別新增變數至 _ShrineAppState,並在輕觸時回呼:

class _ShrineAppState extends State<ShrineApp> {
  Category _currentCategory = Category.all;

  void _onCategoryTap(Category category) {
    setState(() {
      _currentCategory = category;
    });
  }

然後將後層變更為 CategoryMenuPage。

app.dart 中,匯入 CategoryMenuPage:

import 'backdrop.dart';
import 'category_menu_page.dart';
import 'colors.dart';
import 'home.dart';
import 'login.dart';
import 'model/product.dart';
import 'supplemental/cut_corners_border.dart';

build() 函式中,將 backLayer 欄位變更為 CategoryMenuPage,並使用 currentCategory 欄位取得例項變數。

'/': (BuildContext context) => Backdrop(
              // TODO: Make currentCategory field take _currentCategory (104)
              currentCategory: _currentCategory,
              // TODO: Pass _currentCategory for frontLayer (104)
              frontLayer: HomePage(),
              // TODO: Change backLayer field value to CategoryMenuPage (104)
              backLayer: CategoryMenuPage(
                currentCategory: _currentCategory,
                onCategoryTap: _onCategoryTap,
              ),
              frontTitle: const Text('SHRINE'),
              backTitle: const Text('MENU'),
            ),

重新載入並輕觸「選單」按鈕。

Android

iOS

共有 4 種類別的神社菜單

包含 4 個類別的神社選單

如果您輕觸選單選項,目前還沒有任何動作。讓我們一起解決這個問題!

home.dart 中,新增 Category 的變數並傳送至 AsymmetricView。

import 'package:flutter/material.dart';

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

class HomePage extends StatelessWidget {
  // TODO: Add a variable for Category (104)
  final Category category;

  const HomePage({this.category = Category.all, Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    // TODO: Pass Category variable to AsymmetricView (104)
    return AsymmetricView(
      products: ProductsRepository.loadProducts(category),
    );
  }
}

app.dart 中,傳遞 frontLayer_currentCategory

// TODO: Pass _currentCategory for frontLayer (104)
frontLayer: HomePage(category: _currentCategory),

重新載入。輕觸模擬器中的選單按鈕,然後選取類別。

Android

iOS

篩選後的產品頁面

神社篩選產品頁面

已篩除!

關閉選取選單後的前層

backdrop.dart 中,為 _BackdropState 中的 didUpdateWidget() (每當小工具設定變更時呼叫) 函式新增覆寫值:

  // TODO: Add override for didUpdateWidget() (104)
  @override
  void didUpdateWidget(Backdrop old) {
    super.didUpdateWidget(old);

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

儲存專案,觸發熱重載。輕觸選單圖示,然後選取類別。選單應會自動關閉,您應該會看到所選項目的類別。現在,您也需要在前端新增該功能。

切換前層

backdrop.dart 中,為背景圖層新增輕觸回呼:

class _FrontLayer extends StatelessWidget {
  // TODO: Add on-tap callback (104)
  const _FrontLayer({
    Key? key,
    this.onTap, // New code
    required this.child,
  }) : super(key: key);
 
  final VoidCallback? onTap; // New code
  final Widget child;

接著,將 MenuDetector 新增至 _FrontLayer 的子項:Column 的子項:。

      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: <Widget>[
          // TODO: Add a 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: Implement onTap property on _BackdropState (104)
              onTap: _toggleBackdropLayerVisibility,
              child: widget.frontLayer,
            ),
          ),

重新載入並輕觸前景圖層頂端。每次您輕觸前層頂端時,應該都會開啟並關閉圖層。

8. 新增品牌圖示

品牌圖示也適用於常見的圖示。我們來製作揭露圖示,並將其與標題合併,打造獨特的品牌風格。

變更選單按鈕圖示

Android

iOS

含有品牌圖示的 Shrine 產品頁面

含有品牌圖示的 Shrine 產品頁面

backdrop.dart 中,建立新類別 _BackdropTitle。

// TODO: Add _BackdropTitle class (104)
class _BackdropTitle extends AnimatedWidget {
  final void Function() onPress;
  final Widget frontTitle;
  final Widget backTitle;

  const _BackdropTitle({
    Key? key,
    required Animation<double> listenable,
    required this.onPress,
    required this.frontTitle,
    required this.backTitle,
  }) : _listenable = listenable, 
       super(key: key, listenable: listenable);

  final Animation<double> _listenable;

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

    return DefaultTextStyle(
      style: Theme.of(context).textTheme.titleLarge!,
      softWrap: false,
      overflow: TextOverflow.ellipsis,
      child: Row(children: <Widget>[
        // branded icon
        SizedBox(
          width: 72.0,
          child: IconButton(
            padding: const EdgeInsets.only(right: 8.0),
            onPressed: this.onPress,
            icon: Stack(children: <Widget>[
              Opacity(
                opacity: animation.value,
                child: const ImageIcon(AssetImage('assets/slanted_menu.png')),
              ),
              FractionalTranslation(
                translation: Tween<Offset>(
                  begin: Offset.zero,
                  end: const Offset(1.0, 0.0),
                ).evaluate(animation),
                child: const ImageIcon(AssetImage('assets/diamond.png')),
              )]),
          ),
        ),
        // Here, we do a custom cross fade between backTitle and frontTitle.
        // This makes a smooth animation between the two texts.
        Stack(
          children: <Widget>[
            Opacity(
              opacity: CurvedAnimation(
                parent: ReverseAnimation(animation),
                curve: const Interval(0.5, 1.0),
              ).value,
              child: FractionalTranslation(
                translation: Tween<Offset>(
                  begin: Offset.zero,
                  end: const Offset(0.5, 0.0),
                ).evaluate(animation),
                child: backTitle,
              ),
            ),
            Opacity(
              opacity: CurvedAnimation(
                parent: animation,
                curve: const Interval(0.5, 1.0),
              ).value,
              child: FractionalTranslation(
                translation: Tween<Offset>(
                  begin: const Offset(-0.25, 0.0),
                  end: Offset.zero,
                ).evaluate(animation),
                child: frontTitle,
              ),
            ),
          ],
        )
      ]),
    );
  }
}

_BackdropTitle 是自訂小工具,會取代 AppBar 小工具的 title 參數中純文字 Text 小工具。其有動畫選單圖示,以及前後標題之間的動畫轉場效果。動畫選單圖示會使用新的素材資源。您必須將新 slanted_menu.png 的參照新增至 pubspec.yaml

assets:
    - assets/diamond.png
    # TODO: Add slanted menu asset (104)
    - assets/slanted_menu.png
    - packages/shrine_images/0-0.jpg

移除 AppBar 建構工具中的 leading 屬性。您必須移除原始 leading 小工具,才能在原始小工具的位置顯示自訂品牌圖示。動畫 listenable 和品牌圖示的 onPress 處理常式會傳遞至 _BackdropTitle。也會傳遞 frontTitlebackTitle,以便在背景幕標題中算繪。AppBartitle 參數應如下所示:

// TODO: Create title with _BackdropTitle parameter (104)
title: _BackdropTitle(
  listenable: _controller.view,
  onPress: _toggleBackdropLayerVisibility,
  frontTitle: widget.frontTitle,
  backTitle: widget.backTitle,
),

品牌圖示是在 _BackdropTitle. 中建立,其中包含一個動畫圖示 Stack:傾斜選單和鑽石,位於 IconButton 中,可供使用者點選。接著,IconButton 會包裝在 SizedBox 中,為水平圖示動作騰出空間。

Flutter 的「Everything is a widget」架構允許變更預設 AppBar 的版面配置,而不必建立新的自訂 AppBar 小工具。title 參數原本是 Text 小工具,可改用更複雜的 _BackdropTitle。由於 _BackdropTitle 也包含自訂圖示,因此會取代 leading 屬性,現在可以省略了。您可以透過簡單的設定,在不需要變更任何其他參數的情況下,替換小工具,例如繼續自行運作的動作圖示。

新增返回登入畫面的捷徑

backdrop.dart, 中,從應用程式列的兩個結尾圖示新增返回登入畫面的捷徑:根據新用途變更圖示的語意標籤。

        // TODO: Add shortcut to login screen from trailing icons (104)
        IconButton(
          icon: const Icon(
            Icons.search,
            semanticLabel: 'login', // New code
          ),
          onPressed: () {
            // TODO: Add open login (104)
            Navigator.push(
              context,
              MaterialPageRoute(
                builder: (BuildContext context) => LoginPage()),
            );
          },
        ),
        IconButton(
          icon: const Icon(
            Icons.tune,
            semanticLabel: 'login', // New code
          ),
          onPressed: () {
            // TODO: Add open login (104)
            Navigator.push(
              context,
              MaterialPageRoute(
                builder: (BuildContext context) => LoginPage()),
            );
          },
        ),

如果嘗試重新載入,便會收到錯誤訊息。匯入 login.dart 來修正錯誤:

import 'login.dart';

重新載入應用程式,然後輕觸搜尋或調音按鈕,即可返回登入畫面。

9. 恭喜!

在四個程式碼研究室的課程中,您已瞭解如何使用 Material Design 元件,打造獨特且優雅的使用者體驗,展現品牌個性和風格。

後續步驟

本程式碼研究室 MDC-104 會完成這個程式碼研究室系列。您可以前往 Material 元件小工具目錄,探索 Material Flutter 中更多的元件。

針對延展目標,請嘗試將品牌圖示換成 AnimatedIcon,該圖示會在背景顯示時在兩個圖示之間建立動畫效果。

您可以根據興趣嘗試其他許多 Flutter 程式碼研究室。我們也提供另一個 Material Design 專屬程式碼研究室,您可能有興趣瞭解:使用 Material Motion for Flutter 建構精美的轉場效果

我可以在合理的時間內,完成本程式碼研究室

非常同意 同意 中立 不同意 非常不同意

我想日後繼續使用 Material Design 元件

非常同意 同意 普通 不同意 非常不同意