Flutter 用のマテリアル モーションで美しい移行を作成する

マテリアル デザインは、人の目に留まる美しいデジタル プロダクトを作成するためのシステムです。一貫した一連の基本原則とコンポーネントに基づいてスタイル、ブランディング、インタラクション、モーションの統一を行うことにより、プロダクト チームは、デザインの可能性を最大限に発揮できます。

logo_components_color_2x_web_96dp.png

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

Flutter 用マテリアル モーション システムとは何ですか?

マテリアル デザイン ガイドラインに記載されているように、Flutter 用マテリアル モーション システムとは、アニメーション パッケージに含まれている移行パターンのセットです。ユーザーがアプリを直感的に操作しやすくなります。

主なマテリアル移行パターンには次の 4 つの種類があります。

  • コンテナ変換: コンテナを含む UI 要素間の移行。ある要素から別の要素にシームレスな変換を行うことで、2 つの異なる UI 要素の間に視覚的なつながりを作成します。

b9fd67c205755d55.gif

  • 共有軸: 空間関係またはナビゲーション関係がある UI 要素間の移行。x 軸、y 軸、z 軸で共有変換を使用して要素間の関係を強化します。

76622de33a19179.gif

  • フェードスルー: 相互に強力な関係を持たない UI 要素間の移行。受信する要素の規模に応じて、フェードアウトとフェードインを連続的に使用します。

18a525c038443492.gif

  • フェード: 画面の境界内で出入りする UI 要素に使用します。

cd10a0580a159644.gif

アニメーション パッケージには、Flutter アニメーション ライブラリflutter/animation.dart)と Flutter マテリアル ライブラリflutter/material.dart)の双方をベースに作成された、これらのパターン用の移行ウィジェットが用意されています。

この Codelab では、Flutter フレームワークとマテリアル ライブラリをベースにしたマテリアル移行を使用します。つまり、ウィジェットを使った作業を行います。

作成するアプリの概要

この Codelab では、アニメーション パッケージの移行を使用してアプリのデザインをカスタマイズする方法を実習するために、Dart を使用して、Reply というサンプルの Flutter メールアプリにいくつかの移行を作成します。

Reply アプリのスターター コードが提供されます。そして、次のマテリアル移行をアプリに組み込みます。これは次のように、完成した Codelab の GIF に表示されます。

  • メール一覧からメール詳細ページへのコンテナ変換移行
  • FAB からメール作成ページへのコンテナ変換移行
  • 検索アイコンから検索ビューページへの z 軸共有移行
  • メールボックス ページ間のフェードスルー移行
  • 作成と返信 FAB の間のフェードスルー移行
  • 消えていくメールボックス タイトル間のフェードスルー移行
  • ボトム アプリバー アクション間のフェードスルー移行

5f7b8860db2c70e2.gif

必要なもの

  • Flutter の開発と Dart に関する基本的な知識
  • Android Studio(まだお持ちでない場合はこちらからダウンロードしてください)
  • Android Emulator または Android デバイス(Android Studio から入手可能)
  • サンプルコード(次の手順を参照)

Flutter アプリ作成経験についてお答えください。

初心者 中級者 上級者

この Codelab で学びたいことはどんなことですか?

このトピックは初めてなので、簡単に概要を知りたい。 このトピックについてはある程度知っているが、復習したい。 プロジェクトで使用するサンプルコードを探している。 特定の項目に関する説明を確認したい。

準備

Flutter でモバイルアプリを開発するには、以下の作業が必要です。

  1. Flutter SDK をダウンロードしてインストールします。
  2. Flutter SDK で PATH を更新します。
  3. Flutter プラグインと Dart プラグインを搭載した Android Studio をインストールするか、お好みのエディタをインストールします。
  4. Android Emulator、iOS シミュレータ(Xcode が付属している Mac が必要)をインストールするか、物理デバイスを使用します。

Flutter のインストールについて詳しくは、スタートガイド: インストールをご覧ください。エディタの設定方法については、スタートガイド: エディタの設定をご覧ください。Android Emulator をインストールする場合は、最新のシステム イメージを備えた Pixel 3 スマートフォンなどの、デフォルトのオプションを使用してかまいません。VM アクセラレーションの有効化は、推奨されますが必須ではありません。上に述べた 4 つの手順を完了したら、Codelab を続行できます。この Codelab を完了するには、1 つのプラットフォーム(Android または iOS)用の Flutter をインストールするだけで十分です。

Flutter SDK が正しい状態にあることを確認する

この Codelab を先に進める前に、SDK が正しい状態にあることを確認します。Flutter SDK を以前にインストールしてある場合は、flutter upgrade を使用して SDK が最新の状態であることを確認します。

 flutter upgrade

flutter upgrade を実行すると flutter doctor. が自動的に実行されます。これが最新の Flutter インストールでありアップグレードが不要だった場合は、手動で flutter doctor を実行します。設定を完了するためになんらかの依存関係のインストールが必要な場合は、その旨が通知されます。自分に関係がないチェックマークは無視してかまいません(たとえば、iOS 向けの開発を行うつもりがない場合には Xcode を無視できます)。

 flutter doctor

よくある質問

Android Studio を起動する

Android Studio を開くと、「Welcome to Android Studio」というタイトルのウィンドウが表示されます。ただし、Android Studio を初めて起動している場合は、Android Studio 設定ウィザードの各手順を、デフォルト値を設定しながら進めていきます。このステップでは、必要なファイルのダウンロードとインストールに数分間かかる場合があります。バックグラウンドで実行させたまま、次のセクションに取り組んでもかまいません。

オプション 1: GitHub からスターター Codelab アプリのクローンを作成する

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

git clone https://github.com/material-components/material-components-flutter-motion-codelab.git
cd material-components-flutter-motion-codelab

オプション 2: スターター Codelab アプリの zip ファイルをダウンロードする

スターター アプリをダウンロードする

スターター アプリは material-components-flutter-motion-codelab-starter ディレクトリ内にあります。

Android Studio にスターター コードを読み込む

  1. 設定ウィザードが終了し、[Welcome to Android Studio] ウィンドウが表示されたら、[Open an existing Android Studio project] をクリックします。

e3f200327a67a53.png

  1. サンプルコードをインストールしたディレクトリに移動し、サンプル ディレクトリを選択してプロジェクトを開きます。
  2. Android Studio がプロジェクトをビルドして同期するまで待ちます。進捗状況は、Android Studio ウィンドウ下部のアクティビティ インジケーターに表示されます。
  3. この時点では、Android SDK やビルドツール(以下に示すものなど)が不足しているため、Android Studio でビルドエラーが発生する場合があります。Android Studio の指示に従って、それをインストール / 更新して、プロジェクトを同期させます。引き続き問題が発生する場合は、SDK Manager でのツールの更新に関するガイドを参照してください。

6e026ae171f5b1eb.png

  1. プロンプトが表示されたら、次の操作を行います。
  • 任意のプラットフォームとプラグインのアップデートをインストールするか、FlutterRunConfigurationType をインストールします。
  • Dart または Flutter SDK が設定されていない場合は、Flutter プラグインの Flutter SDK パスを設定してください。
  • Android フレームワークを設定します。
  • [Get dependencies] または [Run ‘flutter packages get'] をクリックします。

Android Studio を再起動します。

53b7195f1c1deedb.png

be5ce477ba09225e.png 24810642cf859588.png

プロジェクトの依存関係を確認する

プロジェクトには、アニメーション パッケージへの依存関係が必要です。ダウンロードしたサンプルコードにはこの依存関係がすでにリストされているはずですが、設定を見て確認しましょう。

app モジュールの pubspec.yaml ファイルに移動し、アニメーション セクションへの依存関係が dependencies セクションに含まれていることを確認します。

animations: ^1.1.2

スターター アプリを実行する

  1. デバイス選択の左側にあるビルド構成が app であることを確認します。
  2. 緑色の [Run / Play] ボタンを押して、アプリをビルドし実行します。

a34cba7fab0a2af9.png

  1. エディタ画面の上部にある [Flutter Device Selection] プルダウン メニューを確認し、利用可能なデバイスのリストの中にすでにデバイスがある場合は、ステップ 8 に進みます。それ以外の場合は、[Create New Virtual Device] をクリックします。
  2. [Select Hardware] 画面で Pixel 3 などのスマートフォンを選択し、[Next] をクリックします。
  3. [System Image] 画面で、最新の Android バージョン(可能であれば API レベルが最も高いもの)を選択します。インストールされていない場合は、表示される [Download] をクリックして、ダウンロードを完了します。
  4. [Next] をクリックします。
  5. [Android Virtual Device (AVD)] 画面で、設定をそのまま変更せずに、[Finish] をクリックします。
  6. [Flutter Device Selection] プルダウン メニューからデバイスを選択します(たとえば、<version> 向けにビルドされた Android SDK または iPhone SE など)。
  7. 再生アイコン(b8c998094aa23ac2.png)を押します。
  8. Android Studio によってアプリがビルドおよびデプロイされ、対象デバイスでそのアプリが自動的に開きます。

これで完了です。Reply のホームページのスターター コードがエミュレータで動作しているはずです。メール一覧を含む受信トレイが表示されます。

Android

iOS

省略可: デバイスのアニメーションの速度を下げる

この Codelab には、簡単でありながら洗練された移行が含まれており、実装中の移行の詳細をより細かく確認する目的でデバイスのアニメーションの速度を下げる際に、便利に利用できます。これを実現するには、アプリ内設定(下部ドロワーが開いているときに設定アイコンをタップするとアクセスできます)を使用します。この方法でデバイスのアニメーションの速度を下げても、Reply アプリ外のデバイスのアニメーションには影響しません。

Android

iOS

省略可: ダークモード

Reply のテーマを明るく設定すると目が疲れるという場合にも、対処は可能です。アプリのテーマをダークモードに変更して見やすくできるアプリ内設定が含まれています。この設定には、下部ドロワーが開いているときに設定アイコンをタップするとアクセスできます。

Android

iOS

次に、コードを見てみましょう。アニメーション パッケージを使用してアプリ内の異なる画面間を移行するアプリを用意しています。

  • HomePage: 選択したメールボックスが表示されます。
  • InboxPage: メールの一覧が表示されます。
  • MailPreviewCard: メールのプレビューが表示されます。
  • MailViewPage: 1 通のメールの全文が表示されます。
  • ComposePage: 新しいメールを作成できます。
  • SearchPage: 検索ビューが表示されます。

router.dart

まず、アプリのルート ナビゲーションの設定方法を理解するために、lib ディレクトリで router.dart を開きます。

class ReplyRouterDelegate extends RouterDelegate<ReplyRoutePath>
   with ChangeNotifier, PopNavigatorRouterDelegateMixin<ReplyRoutePath> {
 ReplyRouterDelegate({@required this.replyState})
     : assert(replyState != null),
       navigatorKey = GlobalObjectKey<NavigatorState>(replyState) {
   replyState.addListener(() {
     notifyListeners();
   });
 }

 @override
 final GlobalKey<NavigatorState> navigatorKey;

 RouterProvider replyState;

 @override
 void dispose() {
   replyState.removeListener(notifyListeners);
   super.dispose();
 }

 @override
 ReplyRoutePath get currentConfiguration => replyState.routePath;

 @override
 Widget build(BuildContext context) {
   return MultiProvider(
     providers: [
       ChangeNotifierProvider<RouterProvider>.value(value: replyState),
     ],
     child: Selector<RouterProvider, ReplyRoutePath>(
       selector: (context, routerProvider) => routerProvider.routePath,
       builder: (context, routePath, child) {
         return Navigator(
           key: navigatorKey,
           onPopPage: _handlePopPage,
            pages: [
              // TODO: Add Shared Z-Axis transition from search icon to search view page (Motion)
              const CustomTransitionPage(
                transitionKey: ValueKey('Home'),
                screen: HomePage(),
              ),
              if (routePath is ReplySearchPath)
                const CustomTransitionPage(
                  transitionKey: ValueKey('Search'),
                  screen: SearchPage(),
                ),
            ],
         );
       },
     ),
   );
 }

 bool _handlePopPage(Route<dynamic> route, dynamic result) {
   // _handlePopPage should not be called on the home page because the
   // PopNavigatorRouterDelegateMixin will bubble up the pop to the
   // SystemNavigator if there is only one route in the navigator.
   assert(route.willHandlePopInternally ||
       replyState.routePath is ReplySearchPath);

   final bool didPop = route.didPop(result);
   if (didPop) replyState.routePath = const ReplyHomePath();
   return didPop;
 }

 @override
 Future<void> setNewRoutePath(ReplyRoutePath configuration) {
   assert(configuration != null);
   replyState.routePath = configuration;
   return SynchronousFuture<void>(null);
 }
}

これはルート ナビゲータであり、キャンバス全体を使用するアプリの画面(HomePageSearchPage など)を処理します。これにより、アプリの状態をリッスンし、ルートが ReplySearchPath に設定されたかどうかを確認します。設定されている場合は、スタックの上で SearchPage を使用してナビゲータを再ビルドします。移行が定義されていない CustomTransitionPage では画面がラップされることに注意してください。これは、カスタム移行なしで画面間を移動する 1 つの方法を示しています。

home.dart

home.dart_BottomAppBarActionItems 内で次の操作を行うことにより、アプリの状態の中で ReplySearchPath にルートを設定します。

Align(
   alignment: AlignmentDirectional.bottomEnd,
   child: IconButton(
     icon: const Icon(Icons.search),
     color: ReplyColors.white50,
     onPressed: () {
       Provider.of<RouterProvider>(
         context,
         listen: false,
       ).routePath = ReplySearchPath();
     },
   ),
 );

onPressed パラメータで、RouterProvider にアクセスし、routePathReplySearchPath に設定します。RouterProvider によって、ルート ナビゲータの状態が追跡されます。

mail_view_router.dart

次に、アプリの内部ナビゲーションの設定を見てみましょう。lib ディレクトリの中にある mail_view_router.dart を開きます。上に示すようなナビゲータが表示されます。

class MailViewRouterDelegate extends RouterDelegate<void>
   with ChangeNotifier, PopNavigatorRouterDelegateMixin {
 MailViewRouterDelegate({this.drawerController});

 final AnimationController drawerController;

 @override
 Widget build(BuildContext context) {
   bool _handlePopPage(Route<dynamic> route, dynamic result) {
     return false;
   }

   return Selector<EmailStore, String>(
     selector: (context, emailStore) => emailStore.currentlySelectedInbox,
     builder: (context, currentlySelectedInbox, child) {
       return Navigator(
         key: navigatorKey,
         onPopPage: _handlePopPage,
         pages: [
           // TODO: Add Fade through transition between mailbox pages (Motion)
            CustomTransitionPage(
              transitionKey: ValueKey(currentlySelectedInbox),
              screen: InboxPage(
                destination: currentlySelectedInbox,
              ),
            )
          ],
       );
     },
   );
 }
 ...
}

これは内部ナビゲータです。キャンバスの本文のみを使用するアプリの内部画面(InboxPage など)を処理します。InboxPage には、アプリの状態の中にある現在のメールボックスに応じて、メールの一覧が表示されます。アプリの状態の currentlySelectedInbox プロパティが変更されると、スタックの上で、正しい InboxPage を使用してナビゲータが再ビルドされます。

home.dart

アプリの状態内の現在のメールボックスを設定するには、home.dart_HomePageState 内で次のように操作します。

void _onDestinationSelected(String destination) {
 var emailStore = Provider.of<EmailStore>(
   context,
   listen: false,
 );

 if (emailStore.onMailView) {
   emailStore.currentlySelectedEmailId = -1;
 }

 if (emailStore.currentlySelectedInbox != destination) {
   emailStore.currentlySelectedInbox = destination;
 }

 setState(() {});
}

_onDestinationSelected 関数で、EmailStore にアクセスし、選択されているデスティネーションに currentlySelectedInbox を設定します。EmailStore によって、内部ナビゲータの状態が追跡されます。

home.dart

最後に、使用されているナビゲーション ルーティングの例を確認するには、lib ディレクトリにある home.dart を開きます。InkWell ウィジェットの onTap プロパティ内で、_ReplyFabState クラスを見つけます。次のようになっているはずです。

onTap: () {
 Provider.of<EmailStore>(
   context,
   listen: false,
 ).onCompose = true;

 Navigator.of(context).push(
   PageRouteBuilder(
     pageBuilder: (BuildContext context,
         Animation<double> animation,
         Animation<double> secondaryAnimation,
     ) {
       return const ComposePage();
     },
   ),
 );
}

これは、カスタム移行なしでメール作成ページに移動する方法を示しています。この Codelab では、アプリ中のさまざまなナビゲーション アクションと連動するマテリアル移行を設定するための Reply のコードについて、詳しく説明します。

スターター コードに慣れてきたところで、最初の移行を実装してみましょう。

まず、メールをクリックしたときの移行を追加します。このナビゲーション変更には、コンテナを含む UI 要素間の移行を考慮して設計されたコンテナ変換パターンが適しています。このパターンでは、2 つの UI 要素間に視覚的なつながりが作成されます。

コードを追加する前に、Reply アプリを実行してメールをクリックしてみましょう。単純なジャンプカットが実行されます。つまり、移行なしで画面が切り替わります。

変更前

Android

iOS

まず、次のスニペットに示すように、mail_card_preview.dart の先頭にアニメーション パッケージのインポートを追加します。

mail_card_preview.dart

import 'package:animations/animations.dart';

アニメーション パッケージのインポートが完了したので、アプリに美しい移行を追加できます。まず、OpenContainer ウィジェットを格納する StatelessWidget クラスを作成します。

mail_card_preview.dart で、MailPreviewCard のクラス定義の後に次のコード スニペットを追加します。

mail_card_preview.dart

// TODO: Add Container Transform transition from email list to email detail page (Motion)
class _OpenContainerWrapper extends StatelessWidget {
 const _OpenContainerWrapper({
   @required this.id,
   @required this.email,
   @required this.closedChild,
 })  : assert(id != null),
       assert(email != null),
       assert(closedChild != null);

 final int id;
 final Email email;
 final Widget closedChild;

 @override
 Widget build(BuildContext context) {
   final theme = Theme.of(context);

   return OpenContainer(
     openBuilder: (context, closedContainer) {
       return MailViewPage(id: id, email: email);
     },
     openColor: theme.cardColor,
     closedShape: const RoundedRectangleBorder(
       borderRadius: BorderRadius.all(Radius.circular(0)),
     ),
     closedElevation: 0,
     closedColor: theme.cardColor,
     closedBuilder: (context, openContainer) {
       return InkWell(
         onTap: () {
           Provider.of<EmailStore>(
             context,
             listen: false,
           ).currentlySelectedEmailId = id;
           openContainer();
         },
         child: closedChild,
       );
     },
   );
 }
}

次に、新しいラッパーを使用してみましょう。MailPreviewCard クラス定義の内部で、build() 関数からの return ウィジェットを、新しい _OpenContainerWrapper でラップします。

mail_card_preview.dart

// TODO: Add Container Transform transition from email list to email detail page (Motion)
 return _OpenContainerWrapper(
   id: id,
   email: email,
   closedChild: Material(
     color: theme.cardColor,
     child: InkWell(
       onTap: () {
         Provider.of<EmailStore>(
           context,
           listen: false,
         ).currentlySelectedEmailId = id;

         mobileMailNavKey.currentState.push(
           PageRouteBuilder(
             pageBuilder: (BuildContext context, Animation<double> animation,
                 Animation<double> secondaryAnimation) {
               return MailViewPage(id: id, email: email);
             },
           ),
         );
       },
       child: Dismissible(
         key: ObjectKey(email),
         dismissThresholds: const {
           DismissDirection.startToEnd: 0.8,
           DismissDirection.endToStart: 0.4,
         },
         onDismissed: (direction) {
           switch (direction) {
             case DismissDirection.endToStart:
               if (onStarredInbox) {
                 onStar();
               }
               break;
             case DismissDirection.startToEnd:
               onDelete();
               break;
             default:
           }
         },
         background: _DismissibleContainer(
           icon: 'twotone_delete',
           backgroundColor: colorScheme.primary,
           iconColor: ReplyColors.blue50,
           alignment: Alignment.centerLeft,
           padding: const EdgeInsetsDirectional.only(start: 20),
         ),
         confirmDismiss: (direction) async {
           if (direction == DismissDirection.endToStart) {
             if (onStarredInbox) {
               return true;
             }
             onStar();
             return false;
           } else {
             return true;
           }
         },
         secondaryBackground: _DismissibleContainer(
           icon: 'twotone_star',
           backgroundColor: currentEmailStarred
               ? colorScheme.secondary
               : Theme.of(context).scaffoldBackgroundColor,
           iconColor: currentEmailStarred
               ? colorScheme.onSecondary
               : colorScheme.onBackground,
           alignment: Alignment.centerRight,
           padding: const EdgeInsetsDirectional.only(end: 20),
         ),
         child: mailPreview,
       ),
     ),
   ),
 );
}

この時点でロジックは _OpenContainerWrapper クラスの内部にあるため、ウィジェットから InkWell を削除する操作を忘れずに行ってください。また、内包されるコンテナの色は OpenContainer の色プロパティによって定義されているため、Material ウィジェットも削除できます。

mail_card_preview.dart

// TODO: Add Container Transform transition from email list to email detail page (Motion)
return _OpenContainerWrapper(
 id: id,
 email: email,
 closedChild: Dismissible(
   key: ObjectKey(email),
   dismissThresholds: const {
     DismissDirection.startToEnd: 0.8,
     DismissDirection.endToStart: 0.4,
   }.......

この段階で、コンテナ変換は完全に機能しているはずです。メールをクリックすると、リスト項目が展開されて詳細画面になり、メール一覧が後退します。[戻る] を押すと、メール詳細画面がたたまれてリスト項目に戻り、メール一覧が拡大します。

変更後

Android

iOS

コンテナ変換を続行し、フローティング操作ボタンから ComposePage への移行を追加して、ユーザーによって作成される新しいメールに FAB を展開します。まずアプリを再実行して FAB をクリックし、メール作成画面を開いても移行が行われないことを確認します。

変更前

Android

iOS

この移行設定方法は、前回の手順と同じウィジェット クラス OpenContainer を使用しているため、ほとんど同じ要領で実施できます。

home.dart で、次のスニペットを _ReplyFabState クラス定義に追加し、必ずファイルの先頭に package:animations/animations.dart をインポートします。ここでは、_ReplyFabState クラス定義 build() 関数の戻りウィジェットを OpenContainer ウィジェットでラップしています。

home.dart

// TODO: Add Container Transform from FAB to compose email page (Motion)
return OpenContainer(
 openBuilder: (context, closedContainer) {
   return const ComposePage();
 },
 openColor: theme.cardColor,
 onClosed: (success) {
   Provider.of<EmailStore>(
     context,
     listen: false,
   ).onCompose = false;
 },
 closedShape: circleFabBorder,
 closedColor: theme.colorScheme.secondary,
 closedElevation: 6,
 closedBuilder: (context, openContainer) {
   return Material(
     color: theme.colorScheme.secondary,
     shape: circleFabBorder,
     child: Tooltip(
       message: tooltip,
       child: InkWell(
         customBorder: circleFabBorder,
         onTap: () {
           Provider.of<EmailStore>(
             context,
             listen: false,
           ).onCompose = true;

           Navigator.of(context).push(
             PageRouteBuilder(
               pageBuilder: (BuildContext context,
                   Animation<double> animation,
                   Animation<double> secondaryAnimation,
               ) {
                 return const ComposePage();
               },
             ),
           );
         },
         child: SizedBox(
           height: _mobileFabDimension,
           width: _mobileFabDimension,
           child: Center(
             child: fabSwitcher,
           ),
         ),
       ),
     ),
   );
 },
);

以前の OpenContainer ウィジェットの設定に使用したパラメータに加えて、onClosed も設定されています。onClosed は、OpenContainer ルートがポップしたときまたは閉じた状態に戻ったときに呼び出される ClosedCallback です。そのトランザクションの戻り値は、この関数に引数として渡されます。この Callback を使用して、ComposePage ルートから離れたことを、アプリのプロバイダに通知します。これにより、すべてのリスナーに通知できます。

前回の手順と同様に、ウィジェットから Material ウィジェットを削除します。これは、closedColorclosedBuilder によって返されるウィジェットの色は OpenContainer ウィジェットによって処理されるためです。また、OpenContainer ウィジェットで独自のルーティングが処理されるようになったため、InkWell ウィジェットの onTap 内部の Navigator.push() 呼び出しを削除し、OpenContainer ウィジェットの closedBuilder で指定された openContainer() Callback に置き換えます。

_ReplyFabState クラス定義内の home.dart の内部:

home.dart

// TODO: Add Container Transform from FAB to compose email page (Motion)
return OpenContainer(
 openBuilder: (context, closedContainer) {
   return const ComposePage();
 },
 openColor: theme.cardColor,
 onClosed: (success) {
   Provider.of<EmailStore>(
     context,
     listen: false,
   ).onCompose = false;
 },
 closedShape: circleFabBorder,
 closedColor: theme.colorScheme.secondary,
 closedElevation: 6,
 closedBuilder: (context, openContainer) {
   return Tooltip(
     message: tooltip,
     child: InkWell(
       customBorder: circleFabBorder,
       onTap: () {
         Provider.of<EmailStore>(
           context,
           listen: false,
         ).onCompose = true;
         openContainer();
       },
       child: SizedBox(
         height: _mobileFabDimension,
         width: _mobileFabDimension,
         child: Center(
           child: fabSwitcher,
         ),
       ),
     ),
   );
 },
);

今度は古いコードをクリーンアップします。OpenContainer ウィジェットが onClosed ClosedCallback を介して ComposePage から離れたことをアプリのプロバイダに通知するようになったため、mail_view_router.dart での以前の実装は削除できます。

mail_view_router.dart

// TODO: Add Container Transform from FAB to compose email page (Motion)
emailStore.onCompose = false; /// delete this line
return SynchronousFuture<bool>(false);

これで完了です。FAB から作成画面への移行は次のようになるはずです。

変更後

Android

iOS

このステップでは、検索アイコンから全画面検索ビューへの移行を追加します。このナビゲーション変更には永続的なコンテナが関わってないため、z 軸共有移行を使用して、2 つの画面間の空間関係を強化し、アプリの階層内での 1 段階上昇を示すことができます。

コードを追加する前に、アプリを実行し、画面右下隅の検索アイコンをタップしてみましょう。移行なしで検索ビュー画面が表示されるはずです。

変更前

Android

iOS

まず、router.dart ファイルに移動します。ReplySearchPath クラス定義の後に、次のスニペットを追加します。

router.dart

// TODO: Add Shared Z-Axis transition from search icon to search view page (Motion)
class SharedAxisTransitionPageWrapper extends Page {
  const SharedAxisTransitionPageWrapper(
      {@required this.screen, @required this.transitionKey})
      : assert(screen != null),
        assert(transitionKey != null),
        super(key: transitionKey);

  final Widget screen;
  final ValueKey transitionKey;

  @override
  Route createRoute(BuildContext context) {
    return PageRouteBuilder(
        settings: this,
        transitionsBuilder: (context, animation, secondaryAnimation, child) {
          return SharedAxisTransition(
            fillColor: Theme.of(context).cardColor,
            animation: animation,
            secondaryAnimation: secondaryAnimation,
            transitionType: SharedAxisTransitionType.scaled,
            child: child,
          );
        },
        pageBuilder: (context, animation, secondaryAnimation) {
          return screen;
        });
  }
}

次に、新しい SharedAxisTransitionPageWrapper を使用して、必要な移行を作成します。ウィジェット画面をラッパーでラップして、必要な移行を伴うナビゲータ用ページバック ルートが返されるようにします。ReplyRouterDelegate クラス定義内の pages プロパティで、検索画面を CustomTransitionPage でラップする代わりに、新しいラッパーを使用します。

router.dart

return Navigator(
 key: navigatorKey,
 onPopPage: _handlePopPage,
 pages: [
   // TODO: Add Shared Z-Axis transition from search icon to search view page (Motion)
   const CustomTransitionPage(
     pageBuilder: (context, animation, secondaryAnimation) {
       return const HomePage();
     },
   ),
   if (routePath is ReplySearchPath)
     const SharedAxisTransitionPageWrapper(
       transitionKey: ValueKey('search'),
       screen: const SearchPage(),
     ),
 ],
);

アプリを再実行してみてください。

Android

iOS

視覚的な効果が向上しています。ボトム アプリバーの検索アイコンをクリックすると、共有軸移行によって検索ページが表示領域にスケールインします。ただし、ホームページはスケールアウトせず、検索ページがその上にスケールインするときにも静止したままになります。また、[戻る] ボタンを押してもホームページは表示領域にスケールインせず、検索ページが表示領域からスケールアウトするときにも静止したままになります。つまり、まだ完了していません。

ホームページの移行を修正するには、HomePagerouter.dart 内の SharedAxisTransitionWrapper でラップします。

router.dart

return Navigator(
 key: navigatorKey,
 onPopPage: _handlePopPage,
 pages: [
   // TODO: Add Shared Z-Axis transition from search icon to search view page (Motion)
   const SharedAxisTransitionPageWrapper(
     transitionKey: ValueKey('home'),
     screen: const HomePage(),
   ),
   if (routePath is ReplySearchPath)
     const SharedAxisTransitionPageWrapper(
       transitionKey: ValueKey('search'),
       screen: const SearchPage(),
     ),
 ],
);

これで完了です。アプリをもう一度実行し、検索アイコンをタップしてみてください。ホーム画面と検索ビュー画面は、Z 軸に沿って深さを同時にフェードまたはスケーリングさせ、2 つの画面の間にシームレスな効果をもたらします。

変更後

Android

iOS

このステップでは、異なるメールボックス間の移行を追加します。空間的または階層的な関係は強調したくないため、フェードスルーを使用してメール一覧間の単純な「スワップ」を行います。

コードを追加する前に、アプリを実行し、ボトム アプリバーの Reply ロゴをタップして、メールボックスの切り替えを行ってみましょう。移行なしでメール一覧が切り替わるはずです。

変更前

Android

iOS

まず、mail_view_router.dart ファイルに移動します。MailViewRouterDelegate クラス定義の後に、次のスニペットを追加します。

mail_view_router.dart

// TODO: Add Fade through transition between mailbox pages (Motion)
class FadeThroughTransitionPageWrapper extends Page {
  FadeThroughTransitionPageWrapper({
    @required this.mailbox,
    @required this.transitionKey,
  })  : assert(mailbox != null),
        assert(transitionKey != null),
        super(key: transitionKey);

  final Widget mailbox;
  final ValueKey transitionKey;

  @override
  Route createRoute(BuildContext context) {
    return PageRouteBuilder(
        settings: this,
        transitionsBuilder: (context, animation, secondaryAnimation, child) {
          return FadeThroughTransition(
            fillColor: Theme.of(context).scaffoldBackgroundColor,
            animation: animation,
            secondaryAnimation: secondaryAnimation,
            child: child,
          );
        },
        pageBuilder: (context, animation, secondaryAnimation) {
          return mailbox;
        });
  }
}

前の手順と同様に、新しい FadeThroughTransitionPageWrapper を使用して、必要な移行を作成します。メールボックス画面をラッパーでラップして、フェードスルー移行を伴うナビゲータ用ページバック ルートが返されるようにします。MailViewRouterDelegate クラス定義内の pages プロパティで、メールボックス画面を CustomTransitionPage でラップする代わりに、新しいラッパーを使用します。

mail_view_router.dart

return Navigator(
 key: navigatorKey,
 onPopPage: _handlePopPage,
 pages: [
   // TODO: Add Fade through transition between different mailbox pages (Motion)
   FadeThroughTransitionPageWrapper(
     mailbox: InboxPage(destination: currentlySelectedInbox),
     transitionKey: ValueKey(currentlySelectedInbox),
   ),
 ],
);

アプリを再実行します。ボトム ナビゲーション ドロワーを開いてメールボックスを変更すると、現在のメール一覧がフェードしてスケールアウトする一方で、新しい一覧がフェードしてスケールインします。成功です。

変更後

Android

iOS

このステップでは、異なる FAB アイコン間の移行を追加します。空間的または階層的な関係は強調したくないため、フェードスルーを使用して FAB 内アイコン間の単純な「スワップ」を行います。

コードを追加する前に、アプリを実行し、メールをタップしてメールビューを開いてみてください。移行なしで FAB アイコンが切り替わるはずです。

変更前

Android

iOS

これ以降の Codelab では home.dart での作業を行います。アニメーション パッケージ用のインポートの追加はすでにステップ 2 で home.dart 用に実施済みであるため、ここでは必要ありません。

次のいくつかの移行は、いずれも再利用可能なクラス _FadeThroughTransitionSwitcher を使用するため、設定方法が非常に似ています。

home.dart で、次のスニペットを _ReplyFabState の下に追加します。

home.dart

// TODO: Add Fade through transition between compose and reply FAB (Motion)
class _FadeThroughTransitionSwitcher extends StatelessWidget {
 const _FadeThroughTransitionSwitcher({
   @required this.fillColor,
   @required this.child,
 })  : assert(fillColor != null),
       assert(child != null);

 final Widget child;
 final Color fillColor;

 @override
 Widget build(BuildContext context) {
   return PageTransitionSwitcher(
     transitionBuilder: (child, animation, secondaryAnimation) {
       return FadeThroughTransition(
         fillColor: fillColor,
         child: child,
         animation: animation,
         secondaryAnimation: secondaryAnimation,
       );
     },
     child: child,
   );
 }
}

_ReplyFabStatefabSwitcher ウィジェットを探します。fabSwitcher ウィジェットでは、コンテキストに基づいて FAB を切り替えることができます。fabSwitcher により、メールビューが表示されているかチェックされ、表示されている場合は、FAB 用に別のアイコンのアイコンが付与されます。

home.dart

// TODO: Add Fade through transition between compose and reply FAB (Motion)
static final fabKey = UniqueKey();
static const double _mobileFabDimension = 56;

@override
Widget build(BuildContext context) {
 final theme = Theme.of(context);
 final circleFabBorder = const CircleBorder();

 return Selector<EmailStore, bool>(
   selector: (context, emailStore) => emailStore.onMailView,
   builder: (context, onMailView, child) {
      // TODO: Add Fade through transition between compose and reply FAB (Motion)
     final fabSwitcher = _FadeThroughTransitionSwitcher(
       fillColor: Colors.transparent,
       child: onMailView
           ? Icon(
               Icons.reply_all,
               key: fabKey,
               color: Colors.black,
             )
           : const Icon(
               Icons.create,
               color: Colors.black,
             ),
     );

_FadeThroughTransitionSwitcher には透明な fillColor を指定します。そのため、移行時に要素間の背景はありません。

このステップでは、完全にアニメーション化されたコンテキスト FAB が作成されます。メールビューを表示すると、古い FAB アイコンがフェードしてスケールアウトする一方、新しいアイコンがフェードしてスケールインします。

変更後

Android

iOS

このステップでは、メールビュー表示時にメールボックスのタイトルが表示 / 非表示状態を切り替えながらフェードスルーする、フェードスルー移行を追加します。空間的または階層的な関係は強調したくないため、フェードスルーを使用して、メールボックスのタイトルが含まれる Text ウィジェットと、空の SizedBox の間で、単純な「スワップ」を行います。

コードを追加する前に、アプリを実行し、メールをタップしてメールビューを開いてみてください。移行なしでメールボックスのタイトルが消えるはずです。

変更前

Android

iOS

以降の Codelab は、_FadeThroughTransitionSwitcher の作業のほとんどをすでに前のステップで実施済みなので、すぐに完了します。

home.dart_AnimatedBottomAppBar クラスに移動して、移行を追加します。前のステップの _FadeThroughTransitionSwitcher を再利用して onMailView 条件をラップします。これにより、空の SizedBox か、ボトムドロワーと同期してフェードするメールボックスのタイトルのいずれかが返されます。

home.dart

const SizedBox(width: 8),
const _ReplyLogo(),
const SizedBox(width: 10),
// TODO: Add Fade through transition between disappearing mailbox title (Motion)
_FadeThroughTransitionSwitcher(
 fillColor: Colors.transparent,
 child: onMailView
     ? const SizedBox(height: 0, width: 48)
     : FadeTransition(
         opacity: fadeOut,
         child: Selector<EmailStore, String>(
           selector: (context, emailStore) =>
               emailStore.currentlySelectedInbox,
           builder: (
             context,
             currentlySelectedInbox,
             child,
           ) {
             return Text(
               currentlySelectedInbox,
               style: Theme.of(context)
                   .textTheme
                   .bodyText1
                   .copyWith(
                     color: ReplyColors.white50,
                   ),
             );
           },
         ),
       ),
),

これでこのステップは完了です。

アプリを再実行します。メールを開いてメールビューが表示されると、ボトム アプリバーにあるメールボックスのタイトルがフェードしてスケールアウトします。お疲れさまでした。

変更後

Android

iOS

このステップでは、アプリのコンテキストに基づいてボトム アプリバーのアクションがフェードスルーする、フェードスルー移行を追加します。空間的または階層的な関係は強調したくないため、フェードスルーを使用して、アプリのホームページ、ボトムドロワー、メールビューが表示されているときに、ボトム アプリバー アクション間で単純な「スワップ」を行います。

コードを追加する前に、アプリを実行し、メールをタップしてメールビューを開いてみてください。Reply のロゴをタップしてもけっこうです。移行なしで下部アプリバーのアクションが切り替わるはずです。

変更前

Android

iOS

前回の手順と同様に、もう一度 _FadeThroughTransitionSwitcher を使用します。必要な移行を実現するには、_BottomAppBarActionItems クラス定義に移動し、build() 関数の戻りウィジェットを _FadeThroughTransitionSwitcher でラップします。

home.dart

// TODO: Add Fade through transition between bottom app bar actions (Motion)
return _FadeThroughTransitionSwitcher(
 fillColor: Colors.transparent,
 child: drawerVisible
     ? Align(
         key: UniqueKey(),
         alignment: AlignmentDirectional.bottomEnd,
         child: IconButton(
           icon: const Icon(Icons.settings),
           color: ReplyColors.white50,
           onPressed: () async {
             drawerController.reverse();
             showModalBottomSheet(
               context: context,
               shape: RoundedRectangleBorder(
                 borderRadius: modalBorder,
               ),
               builder: (context) => const SettingsBottomSheet(),
             );
           },
         ),
       )
     : onMailView
         ? Row(
             mainAxisSize: MainAxisSize.max,
             mainAxisAlignment: MainAxisAlignment.end,
             children: [
               IconButton(
                 icon: ImageIcon(
                   const AssetImage(
                     '$_iconAssetLocation/twotone_star.png',
                     package: _assetsPackage,
                   ),
                   color: starIconColor,
                 ),
                 onPressed: () {
                   model.starEmail(
                     model.currentlySelectedInbox,
                     model.currentlySelectedEmailId,
                   );
                   if (model.currentlySelectedInbox == 'Starred') {
                     mobileMailNavKey.currentState.pop();
                     model.currentlySelectedEmailId = -1;
                   }
                 },
                 color: ReplyColors.white50,
               ),
               IconButton(
                 icon: const ImageIcon(
                   AssetImage(
                     '$_iconAssetLocation/twotone_delete.png',
                     package: _assetsPackage,
                   ),
                 ),
                 onPressed: () {
                   model.deleteEmail(
                     model.currentlySelectedInbox,
                     model.currentlySelectedEmailId,
                   );

                   mobileMailNavKey.currentState.pop();
                   model.currentlySelectedEmailId = -1;
                 },
                 color: ReplyColors.white50,
               ),
               IconButton(
                 icon: const Icon(Icons.more_vert),
                 onPressed: () {},
                 color: ReplyColors.white50,
               ),
             ],
           )
         : Align(
             alignment: AlignmentDirectional.bottomEnd,
             child: IconButton(
               icon: const Icon(Icons.search),
               color: ReplyColors.white50,
               onPressed: () {
                 Provider.of<RouterProvider>(
                   context,
                   listen: false,
                 ).routePath = ReplySearchPath();
               },
             ),
           ),
);

では、試してみましょう。メールを開いてメールビューが表示されると、古いボトム アプリバーのアクションがフェードしてスケールアウトする一方で、新しいアクションがフェードしてスケールインします。お疲れさまでした。

変更後

Android

iOS

アニメーション パッケージは、100 行未満の Dart コードを使用して、マテリアル デザインのガイドラインに準拠した美しい移行を既存のアプリに作成する作業を支援します。また、すべてのデバイスで一貫した外観と動作を実現します。

Android

iOS

次の手順

マテリアル モーション システムの詳細については、仕様デベロッパー向けドキュメント全文を確認し、いくつかのマテリアル移行をアプリに追加してみてください。

マテリアル モーションをお試しいただきありがとうございます。この Codelab がお役に立ちましたら幸いです。

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

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

今後もマテリアル モーション システムを使いたい

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

Material Flutter ライブラリで提供されるウィジェットの使用方法を紹介するその他のデモと、Flutter フレームワークについては、Flutter ギャラリーをご覧ください。

52f7119a30bb8f5c.png

dd11628e4c0f3fd3.png