MDC-104 Flutter:Material 進階元件

MDC-104 Flutter:Material 進階元件

程式碼研究室簡介

subject上次更新時間:6月 6, 2023
account_circle作者:Material Flutter Team

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 元件