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

1. はじめに

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

logo_components_color_2x_web_96dp.png

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

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

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

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

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

11807bdf36c66657.gif

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

71218f390abae07e.gif

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

385ba37b8da68969.gif

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

cfc40fd6e27753b6.gif

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

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

作成するアプリの概要

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

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

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

b26fe84fed12d17d.gif

必要なもの

  • Flutter の開発と Dart に関する基本的な知識
  • コードエディタ
  • Android / iOS 用のエミュレータまたはデバイス
  • サンプルコード(次の手順を参照)

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

初心者 中級者 上級者

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

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

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

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

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

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

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

オプション 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 ディレクトリ内にあります。

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

プロジェクトはアニメーション パッケージに依存します。pubspec.yaml の dependencies セクションに、以下のコードが含まれています。

animations: ^2.0.0

プロジェクトを開いてアプリを実行する

  1. お使いのエディタでプロジェクトを開きます。
  2. エディタで Get Started: Test drive を開き、「Run the app」セクションの指示に従います。

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

Reply のホームページ

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

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

d23a7bfacffac509.gif

省略可: ダークモード

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

87618d8418eee19e.gif

4. サンプルアプリのコードを習得する

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

  • 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})
     : 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) {
   replyState.routePath = configuration;
   return SynchronousFuture<void>(null);
 }
}

これはルート ナビゲータであり、キャンバス全体を使用するアプリの画面(HomePage や SearchPage など)を処理します。これにより、アプリの状態をリッスンし、ルートが 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 = const ReplySearchPath();
   },
 ),
);

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

mail_view_router.dart

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

class MailViewRouterDelegate extends RouterDelegate<void>
   with ChangeNotifier, PopNavigatorRouterDelegateMixin {
 MailViewRouterDelegate({required 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 のコードについて、詳しく説明します。

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

5. メール一覧からメール詳細ページへのコンテナ変換移行を追加する

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

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

変更前

48b00600f73c7778.gif

まず、次のスニペットに示すように、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,
 });

 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() 関数からの Material ウィジェットを、新しい _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(
...

_OpenContainerWrapper には InkWell ウィジェットがあり、内包されるコンテナの色は OpenContainer の色のプロパティによって定義されているため、マテリアル ウィジェットと Inkwell ウィジェットは削除が可能です。変更後のコードは次のようになります。

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,
   },
   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.scaffoldBackgroundColor,
     iconColor: currentEmailStarred
         ? colorScheme.onSecondary
         : colorScheme.onBackground,
     alignment: Alignment.centerRight,
     padding: const EdgeInsetsDirectional.only(end: 20),
   ),
   child: mailPreview,
 ),
);

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

変更後

663e8594319bdee3.gif

6. FAB からメール作成ページへのコンテナ変換移行を追加する

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

変更前

4aa2befdc5170c60.gif

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

home.dart で、package:animations/animations.dart をファイルの先頭にインポートして _ReplyFabState build() メソッドを変更してみましょう。返された Material ウィジェットを 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,
     ...

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

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

変更後のコードは次のようになります。

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>(true);

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

変更後

5c7ad1b4b40f9f0c.gif

7. 検索アイコンから検索ビューページへの z 軸共有移行を追加する

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

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

変更前

df7683a8ad7b920e.gif

まず、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})
     : 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 の代わりに SharedAxisTransitionPageWrapper でラップします。

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(
     transitionKey: ValueKey('Home'),
     screen: HomePage(),
   ),
   if (routePath is ReplySearchPath)
     const SharedAxisTransitionPageWrapper(
       transitionKey: ValueKey('Search'),
       screen: SearchPage(),
     ),
 ],
);

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

81b3ea098926931.gif

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

HomePage も CustomTransitionPage ではなく SharedAxisTransitionWrapper でラップすることで、この 2 つの問題を解決できます。

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: HomePage(),
   ),
   if (routePath is ReplySearchPath)
     const SharedAxisTransitionPageWrapper(
       transitionKey: ValueKey('search'),
       screen: SearchPage(),
     ),
 ],
);

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

変更後

462d890086a3d18a.gif

8. メールボックス ページ間のフェードスルー移行を追加する

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

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

変更前

89033988ce26b92e.gif

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

mail_view_router.dart

// TODO: Add Fade through transition between mailbox pages (Motion)
class FadeThroughTransitionPageWrapper extends Page {
 const FadeThroughTransitionPageWrapper({
   required this.mailbox,
   required this.transitionKey,
 })  : 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 でラップする代わりに、FadeThroughTransitionPageWrapper を使用します。

mail_view_router.dart

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

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

変更後

8186940082b630d.gif

9. 作成と返信 FAB の間のフェードスルー移行を追加する

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

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

変更前

d8e3afa0447cfc20.gif

これ以降の 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,
 });

 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,
   );
 }
}

_ReplyFabState で fabSwitcher ウィジェットを探します。fabSwitcher は、メールビューかどうかに基づいて異なるアイコンを返します。_FadeThroughTransitionSwitcher でラップしましょう。

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 を指定します。そのため、移行時に要素間の背景はありません。UniqueKey も作成して、アイコンの 1 つに適用します。

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

変更後

c55bacd9a144ec69.gif

10. 非表示メールボックス タイトル間のフェードスルー移行を追加する

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

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

変更前

59eb57a6c71725c0.gif

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

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

home.dart

...
const _ReplyLogo(),
const SizedBox(width: 10),
// TODO: Add Fade through transition between disappearing mailbox title (Motion)
_FadeThroughTransitionSwitcher(
 fillColor: Colors.transparent,
 child: onMailView
     ? const SizedBox(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,
                   ),
             );
           },
         ),
       ),
),

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

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

変更後

3f1a3db01a481124.gif

11. ボトム アプリバー アクション間のフェードスルー移行を追加する

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

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

変更前

5f662eac19fce3ed.gif

前回の手順と同様に、もう一度 _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
...

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

変更後

cff0fa2afa1c5a7f.gif

12. 完了

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

d5637de49eb64d8a.gif

次の手順

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

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

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

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

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

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

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

46ba920f17198998.png

6ae8ae284bf4f9fa.png