MDC-104 Flutter: マテリアルの高度なコンポーネント

1. はじめに

logo_components_color_2x_web_96dp.png

マテリアル コンポーネント(MDC)は、デベロッパーがマテリアル デザインを実装する際に役立ちます。Google のエンジニアと UX デザイナーのチームが作成した MDC には、美しく機能的な UI コンポーネントが多数含まれており、Android、iOS、ウェブ、Flutter.material.io/develop に利用可能です。

Codelab MDC-103 では、アプリのスタイルを設定するマテリアル コンポーネント(MDC)のカラー、高度、タイポグラフィ、シェイプをカスタマイズしました。

マテリアル デザイン システムのコンポーネントは、事前定義されたタスクのセットを実行します。また、ボタンなど、特定の特性を持っています。ボタンは単にユーザーがアクションを実行するための手段であるだけでなく、形状、サイズ、色を備えた視覚的表現でもあり、UI が操作可能であることと、タップまたはクリックすると何かが起こることをユーザーに伝えます。

マテリアル デザイン ガイドラインは、デザイナーの観点から見たコンポーネントの記述です。複数のプラットフォームで利用できるさまざまな基本機能と、各コンポーネントを構成する微細な要素について説明しています。たとえば、背景(バックドロップ)には、バックレイヤとそのコンテンツ、フロントレイヤとそのコンテンツ、モーション ルール、表示オプションが含まれます。それぞれのコンポーネントは、アプリのニーズ、ユースケース、コンテンツに合わせてカスタマイズできます。

作成するアプリの概要

この Codelab では、Shrine アプリの UI を、2 つのレベルからなる「背景」というプレゼンテーションに変更します。背景には、選択可能なカテゴリを一覧表示するメニューが含まれています。カテゴリは、非対称グリッドに表示される商品をフィルタするために使用されます。この Codelab では、以下の Flutter コンポーネントを使用します。

  • シェイプ
  • モーション
  • Flutter ウィジェット(前の Codelab で使用したもの)

Android

iOS

ピンクと茶色のテーマが設定された e コマースアプリ。上部にアプリバーがあり、横方向にスクロール可能な非対称のグリッドに商品が表示されています。

ピンクと茶色のテーマが設定された e コマースアプリ。上部にアプリバーがあり、横方向にスクロール可能な非対称のグリッドに商品が表示されています。

メニューに 4 つのカテゴリが表示されています

メニューに 4 つのカテゴリが表示されています

この Codelab の MDC-Flutter コンポーネントとサブシステム

  • シェイプ

Flutter 開発の経験についてお答えください。

初心者 中級者 上級者

2. Flutter の開発環境をセットアップする

このラボを完了するには、Flutter SDKエディタの 2 つのソフトウェアが必要です。

この Codelab は、次のいずれかのデバイスを使って実行できます。

  • パソコンに接続され、デベロッパー モードに設定された物理デバイス(Android または iOS
  • iOS シミュレータ(Xcode ツールのインストールが必要)
  • Android Emulator(Android Studio でセットアップが必要)
  • ブラウザ(デバッグには Chrome が必要)
  • WindowsLinuxmacOS のデスクトップ アプリケーション。開発はデプロイする予定のプラットフォームで行う必要があります。たとえば、Windows のデスクトップ アプリを開発する場合は、適切なビルドチェーンにアクセスできるように Windows で開発する必要があります。オペレーティング システム固有の要件については、docs.flutter.dev/desktop に詳しい説明があります。

3. Codelab のスターター アプリをダウンロードする

MDC-103 から続行する場合

MDC-103 が完了済みであれば、コードはこの Codelab を進められる状態になっています。「背景メニューを追加する」ステップに進んでください。

ゼロから始める

スターター アプリは material-components-flutter-codelabs-104-starter_and_103-complete/mdc_100_series ディレクトリにあります。

GitHub からクローンを作成する

GitHub からこの Codelab のクローンを作成するには、次のコマンドを実行します。

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. エディタで Get Started: Test drive を開き、「Run the app」セクションの指示に従います。

完了しました。前の Codelab で作成した Shrine のログインページがデバイスに表示されます。

Android

iOS

Shrine ログインページ

Shrine ログインページ

4. 背景メニューを追加する

背景は、他のすべてのコンテンツとコンポーネントの背後に表示されます。背景は、バックレイヤ(アクションとフィルタを表示)とフロントレイヤ(コンテンツを表示)の 2 つのレイヤで構成されます。背景を使用して、ナビゲーションやコンテンツ フィルタなどのインタラクティブな情報とアクションを表示できます。

ホーム アプリバーを削除する

HomePage ウィジェットは、フロントレイヤのコンテンツになります。現在、このウィジェットにはアプリバーがあります。アプリバーをバックレイヤに移動し、HomePage に AsymmetricView のみが含まれるようにします。

home.dart で、build() 関数を変更して AsymmetricView のみを返すようにします。

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

Backdrop ウィジェットを追加する

frontLayerbackLayer を含む Backdrop という名前のウィジェットを作成します。

backLayer には、カテゴリを選択してリストをフィルタできるメニュー(currentCategory)が含まれています。メニュー選択機能を維持するため、Backdrop をステートフル ウィジェットにします。

backdrop.dart という名前の新しいファイルを /lib に追加します。

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() 関数は、以前の HomePage と同じく、アプリバーを含む Scaffold を返します。ただし、Scaffold の本文はスタックです。スタックの子はオーバーラップできます。それぞれの子のサイズと場所は、スタックの親を基準にして指定されます。

次に、ShrineApp に Backdrop インスタンスを追加します。

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, で、HomePagefrontLayerBackdrop を返して / ルートを変更します。

// 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

背景色がピンクの Shrine 商品ページ

背景色がピンクの Shrine 商品ページ

backLayer により、frontLayer のホームページの背後にある新しいレイヤにピンクの領域が表示されます。

Flutter Inspector を使用すると、実際には HomePage の背後に Stack の Container があることを確認できます。画面では次のように表示されます。

4783ed30f1cc010.png

以上で、2 つのレイヤのデザインとコンテンツを調整できるようになりました。

5. シェイプを追加する

このステップでは、左上隅をカットしたスタイルをフロントレイヤに設定します。

マテリアル デザインでは、このようなカスタマイズが可能なコンポーネントをシェイプと呼びます。マテリアルのサーフェスは任意のシェイプを持つことができます。シェイプによってサーフェスに強調とスタイルを加え、ブランドを表現できます。通常の四角形のシェイプは、カーブまたは角度の付いた隅と縁、および任意の数の辺を使用してカスタマイズできます。また、対称または非対称にできます。

フロントレイヤにシェイプを追加する

角度が付いた Shrine のロゴは、Shrine アプリのシェイプ ストーリーに影響を与えました。シェイプ ストーリーとは、アプリ全体に適用されるシェイプの一般的な使用方法です。たとえば、ロゴのシェイプは、シェイプが適用されているログインページの各要素に反映されます。このステップでは、左上隅を角度を付けてカットしたスタイルをフロントレイヤに設定します。

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 商品ページ

カスタム シェイプが設定された Shrine 商品ページ

これで、Shrine のプライマリ サーフェスにカスタムのシェイプが設定されました。サーフェスの高度により、白いフロントレイヤのすぐ後ろに何かがあることがユーザーにわかります。ユーザーが背景のバックレイヤを見ることができるように、モーションを追加しましょう。

6. モーションを追加する

モーションは、アプリに命を吹き込む手段です。モーションは、大きくて目立つものでも、小さくて微妙なものでも、その中間のどんなものでもかまいません。ただし、使用するモーションが状況に適したものになるように留意する必要があります。反復される定期的なアクションに適用するモーションは、ユーザーの注意を逸らさないように、また、動作に時間がかかりすぎないように、小さくて微妙なものにする必要があります。一方、ユーザーが初めてアプリを開いたときのように、ユーザーの目を惹きつけることが適切な場合もあります。ある種のアニメーションは、アプリの使い方をユーザーに教えるのに役立ちます。

メニューボタンに開示モーションを追加する

backdrop.dart の先頭で、クラスまたは関数のスコープの外部に、アニメーションに要求される速度を表す定数を追加します。

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

_BackdropState に AnimationController ウィジェットを追加し、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 は、Animation を調整するとともに、アニメーションの再生、巻き戻し、停止を行う 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);
  }

ExcludeSemantics ウィジェットで backLayer をラップします。このウィジェットは、バックレイヤが表示されないときに、セマンティクス ツリーから backLayer のメニュー項目を除外します。

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

_buildStack() 関数を変更して、BuildContext と BoxConstraints を受け取るようにします。また、RelativeRectTween Animation を受け取る 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,
          ),
        ),
      ],
    );
  }

最後に、Scaffold の本文の _buildStack 関数を呼び出す代わりに、_buildStack をビルダーとして使用する LayoutBuilder ウィジェットを返します。

    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

空の Shrine メニューに 2 個のエラーが表示されています

空の Shrine メニューに 2 個のエラーが表示されています

フロントレイヤにアニメーションが設定され、下にスライドできるようになりました。しかし、スライドして下を見ると、赤いエラーとオーバーフロー エラーが表示されます。これは、AsymmetricView がスクイーズされてこのアニメーションの分だけ小さくなり、それによって Column のスペースが狭くなるためです。最終的には、Column は与えられたスペースに自身をレイアウトできなくなり、その結果エラーが発生します。Column を ListView に置き換えると、アニメーションの際に列のサイズが維持されます。

商品の列を ListView でラップする

supplemental/product_columns.dart で、OneProductCardColumn 内の Column を 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>[
        const SizedBox(
          height: 40.0,
        ),
        ProductCard(
          product: product,
        ),
      ],
    );
  }
}

Column には MainAxisAlignment.end が含まれています。最下部からレイアウトを開始するには、reverse: true のマークを付けます。変更を補正するため、子の順序が逆になります。

再読み込みを行い、メニューボタンをタップします。

Android

iOS

空の Shrine メニューに 1 個のエラーが表示されています

空の Shrine メニューに 1 個のエラーが表示されています

OneProductCardColumn のグレーのオーバーフロー警告が表示されなくなりました。他のエラーも修正しましょう。

supplemental/product_columns.dart で、imageAspectRatio の計算方法を変更し、TwoProductCardColumn 内の Column を 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.bodyText1,
                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.bodyText1!.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()),
      ),
    );
  }
}

これは、子がカテゴリ名である Column をラップする GestureDetector です。選択されたカテゴリを示すために、アンダースコアが使用されています。

app.dart で、ShrineApp ウィジェットをステートレスからステートフルに変換します。

  1. ShrineApp. をハイライト表示します。
  2. ご使用の IDE でコード アクションを表示します。
  3. Android Studio: ⌥Enter(macOS)または alt + enter を押します。
  4. VS Code: ⌘.(macOS)または Ctrl+ を押します。
  5. [Convert to StatefulWidget] を選択します。
  6. ShrineAppState クラスをプライベート クラス(_ShrineAppState)に変更します。ShrineAppState を右クリックして、次の操作を行います。
  7. Android Studio: [Refactor] > [Rename] を選択します。
  8. VS Code: [Rename Symbol] を選択します。
  9. プライベート クラスにするため、「_ShrineAppState」と入力します。

app.dart で、選択された Category の _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

Shrine メニューに 4 個のカテゴリが表示されています

Shrine メニューに 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

フィルタされた Shrine 商品ページ

フィルタされた Shrine 商品ページ

メニュー アイコンをタップすると、商品が表示されます。商品がフィルタされています。

メニューの選択後にフロントレイヤを閉じる

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 で、背景レイヤに on-tap コールバックを追加します。

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;

次に、_FrontLayer の子である Column の子に GestureDetector を追加します。

      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.headline6!,
      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 ウィジェットの位置にレンダリングするためです。ブランド アイコンのアニメーション listenableonPress ハンドラが _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 の「すべてがウィジェット」方式のアーキテクチャでは、まったく新しいカスタム AppBar ウィジェットを作成しなくても、デフォルトの AppBar のレイアウトを変更できます。title パラメータは元々は Text ウィジェットでしたが、より複雑な _BackdropTitle に置き換えることができます。また、_BackdropTitle にはカスタム アイコンが含まれているため、leading プロパティの代わりに使用できます。つまり、このプロパティを削除できます。このシンプルなウィジェットの置換は、他のパラメータ(アクション アイコンなど)の変更なしで実現され、他のパラメータは独立して引き続き機能します。

ログイン画面に戻るショートカットを追加する

backdrop.dart, で、アプリバーの末尾の 2 つのアイコンからログイン画面に戻るショートカットを追加します。アイコンのセマンティック ラベルは、新しい目的を示すものに変更します。

        // 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. 完了

4 つの Codelab を実践することにより、マテリアル コンポーネントを使用して、ブランドの個性とスタイルを表現する独自の洗練されたユーザー エクスペリエンスを構築する方法を学びました。

次のステップ

この Codelab MDC-104 で一連の Codelab は完了です。マテリアル コンポーネント ウィジェット カタログには、さらに多くの MDC-Flutter のコンポーネントがあります。

難度の高い目標に挑戦する場合は、ブランド アイコンを AnimatedIcon に置き換えてみてください。これは、背景が表示されているときに 2 つのアイコンの間でアニメーションが動作するアイコンです。

このほかにも多くの Flutter Codelab があります。関心のあるものをお試しください。また、マテリアル関連の別の Codelab も用意しています。関心がある方は、Flutter のマテリアル モーションで見栄えの良い遷移を作成するをご覧ください。

この Codelab を完了するためにそれなりの時間と労力を必要とした

非常にそう思う そう思う どちらとも言えない そう思わない まったくそう思わない

今後もマテリアル コンポーネントを使用したい

非常にそう思う そう思う どちらとも言えない そう思わない まったくそう思わない