paint-brush
Flutter でスタッガードアニメーションを作成する: マイクロインタラクションガイド@nikkieke
460 測定値
460 測定値

Flutter でスタッガードアニメーションを作成する: マイクロインタラクションガイド

19m2024/06/04
Read on Terminal Reader

長すぎる; 読むには

マイクロインタラクションの概念は、小さな詳細が全体的なユーザーエクスペリエンスに大きな影響を与える可能性があるという考えに基づいています。Flutter では、暗黙的または明示的なアニメーションを使用して微妙なアニメーションを作成することで、マイクロインタラクションを構築できます。この記事では、ページがスワイプされたときに列ウィジェットの子をアニメーション化する、ずらしたアニメーションを作成します。
featured image - Flutter でスタッガードアニメーションを作成する: マイクロインタラクションガイド
undefined HackerNoon profile picture
0-item

スタッガード アニメーションは、連続したアニメーションまたは重なり合ったアニメーションで構成されます。アニメーションは、1 つの変更が次の変更の後に発生する完全に連続的なものである場合もあれば、部分的または完全に重なり合う場合もあります。アニメーションが連続的である場合、各要素の開始時間の間にわずかな遅延があり、要素は連続的にアニメーション化されます。これにより、カスケード効果または波及効果が生まれ、アニメーションは一度にではなく段階的に要素間を移動しているように見えます。


スタッガードアニメーションは、ユーザーをインターフェースを通じてガイドする微妙でインタラクティブなフィードバックを提供することでユーザーエクスペリエンスを向上させるため、マイクロインタラクションの一種と見なされます。Flutter では、暗黙的または明示的なアニメーションを使用して微妙なアニメーションを作成することで、マイクロインタラクションを構築できます。


コンテキストでは、暗黙的なアニメーションはアニメーションの詳細が抽象化されているため、シンプルで使いやすいように設計されていますが、明示的なアニメーションはアニメーション プロセスを完全に制御できるため、複雑なアニメーションに適しています。これは、アニメーションをより細かく制御する必要がある場合に特に使用されます。


この記事では、マイクロインタラクションの概念を詳しく説明し、マイクロインタラクションのユースケースとして、明示的なアニメーションを使用して、列ウィジェットに含まれる子要素をアニメーション化する段階的なアニメーションを作成します。

マイクロインタラクションの概念とは何ですか?

優れた製品とは、機能と詳細の両方が優れている製品です。機能は人々を製品に引き付けますが、詳細が人々を惹きつけます。これらの詳細が、あなたのアプリを他のアプリより際立たせるものです。マイクロインタラクションを使用すると、ユーザーに楽しいフィードバックを提供することで、これらの詳細を作成できます。


マイクロインタラクションの概念は、小さな詳細が全体的なユーザーエクスペリエンスに大きな影響を与える可能性があるという考えに基づいています。マイクロインタラクションは、フィードバックやアクションの結果を伝えるなどの重要な機能を果たすために使用できます。マイクロインタラクションの例は次のとおりです。


  • ボタンのアニメーション: ボタンにマウスを置いたり、ボタンを押したりすると、ボタンの色やサイズが変わります。


  • 読み込みインジケーター: プロセスが進行中であることをユーザーに示すアニメーション。


  • スワイプ ジェスチャ: スワイプ ジェスチャに反応するアニメーション。


  • ナビゲーション遷移: 画面間の遷移時のスムーズなアニメーション。

現実のアプリケーションにおけるマイクロインタラクション

以下では、マイクロインタラクションの実際のアプリケーションを見ることができます。これらの微妙なアニメーションは、ユーザーエクスペリエンスを向上させるために Flutter で作成されています。👇🏽

これらのアプリのデザイン参考資料はDribbbleから入手しました

Flutter でスタッガードアニメーションを作成する方法

この例では、ページがスワイプされたときに列ウィジェット内の子をアニメーション化する、ずらしたアニメーションを作成します。この場合、アニメーションの実行方法を完全に制御する必要があるため、明示的なアニメーション アプローチを使用して構築されます。


完成したアニメーションは次のようになります👇🏽

前提条件

このチュートリアルを最大限に活用するには、次のものが必要です。


  • Flutterでアニメーションがどのように作成されるかについての基本的な理解
  • FlutterとDartの基礎を十分に理解している
  • コードエディタ(VScodeまたはAndroid Studio)
  • 構築するためのエミュレータまたはデバイス


プロジェクトの前提条件をすべて確認したら、早速始めましょう。


まず、これら 2 つのページを含むメイン画面を構築します。これら 2 つのページは、スワイプ時にどのページが表示されるかを制御するページビュー ウィジェットにラップされます。また、メイン画面の下部には、現在表示されているページを示すインジケーターがあります。

 class MainScreen extends StatefulWidget { const MainScreen({super.key}); @override State<MainScreen> createState() => _MainScreenState(); } class _MainScreenState extends State<MainScreen>{ final controller = PageController(keepPage: true); @override Widget build(BuildContext context) { return Scaffold( body: SafeArea( child: PageView( controller: controller, children: [ Page1(pageController: controller,), Page2(pageController: controller,), ], ), ), bottomNavigationBar: Container( alignment: Alignment.topCenter, padding: const EdgeInsets.only(top: 10), height: 30, child: SmoothPageIndicator( controller: controller, count: 2, effect: const JumpingDotEffect( dotHeight: 10, dotWidth: 10, activeDotColor: Colors.grey, dotColor: Colors.black12, ), ), ) ); } }


上記のコード スニペットで使用されているSmoothPageIndicatorpub.devにあります。ここでは、 SmoothPageIndicatorは現在表示されているページを表示するために使用されています。次のように、 pubspec.yamlにパッケージを追加できます 👇🏽

 dependencies: flutter: sdk: flutter smooth_page_indicator: ^1.1.0


2 つのページには、いくつかの空のカード ウィジェットを含む列ウィジェットがあります。これらの空のカード ウィジェットは、ずらしたアニメーションを完全に表示して評価できるように、列にデータを入力するために使用されます。空のカード ウィジェットは、再利用可能なコンポーネントとして作成します。これにより、使用するすべての場所でゼロから構築する必要がなくなります。

 class EmptyCard extends StatelessWidget { const EmptyCard({super.key, required this.width, required this.height}); final double width; final double height; @override Widget build(BuildContext context) { return Container( height: height, width: width, decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(10)), color: Colors.blue.shade200, boxShadow: const [ BoxShadow( color: Colors.black12, blurRadius: 4, offset: Offset(0,4), ) ] ), ); } }


これらすべての定型コードが終わったら、アニメーションの構築を始めます。まず、 AnimateWidgetという新しいステートフル ウィジェットを作成します。このウィジェットには、アニメーションの実行方法を制御するためのいくつかのパラメーターがあります。

 class AnimateWidget extends StatefulWidget { const AnimateWidget({super.key, required this.duration, required this.position, required this.horizontalOffset, required this.child, required this.controller, }); final Duration duration; final int position; final double? horizontalOffset; final Widget child; final PageController controller; @override State<AnimateWidget> createState() => _AnimateWidgetState(); }


上記のスニペットに示されているAnimateWidgetのパラメータを使用すると、次のものを正常に制御できます。

  • アニメーションの継続時間。
  • pageController 、ページがスワイプされたときにアニメーションをトリガーします。
  • アニメーション要素の水平オフセットの量。


次に、 AnimateWigetで以下を定義します。

  • AnimationController : アニメーションシーケンスを制御するために使用されます。

  • 現在のページ: 現在のページデータを保持するために使用されます。

  • タイマー: 一定の遅延後にアニメーションをトリガーするために使用されます。これにより、段階的なアニメーションのカスケード効果が得られます。


     class _AnimateWidgetState extends State<AnimateWidget> with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin{ @override bool get wantKeepAlive => true; late AnimationController animationController; int currentPage = 0; Timer? _timer; @override void initState() { super.initState(); animationController = AnimationController(vsync: this, duration: widget.duration); _timer = Timer(getDelay(), animationController.forward); widget.controller.addListener(() { currentPage = widget.controller.page!.round(); if(currentPage == widget.controller.page){ _timer = Timer(getDelay(), animationController.forward); } }); } @override void dispose() { _timer?.cancel(); animationController.dispose(); super.dispose(); } Duration getDelay(){ var delayInMilliSec = widget.duration.inMilliseconds ~/ 6; int getStaggeredListDuration(){ return widget.position * delayInMilliSec; } return Duration(milliseconds: getStaggeredListDuration()); }

上記のコード スニペットを分解すると、次のことがわかります。

  • アニメーション シーケンスを制御するためにAnimationControllerを使用したため、 SingleTickerProviderStateMixinを導入しました。


  • init stateメソッドでは、 AnimationController初期化し、ページエントリ時およびページスワイプ時にanimationController.forwardを使用してアニメーションをトリガーしました。


  • タイマーを使用して、遅延に基づいてアニメーションのトリガーを制御しました。


  • disposeメソッドでは、リソースをクリーンアップしました。


  • ウィジェットの状態を保持するためにAutomaticKeepAliveClientMixinを使用しました。これは、ページがスワイプされて表示されなくなったときにAnimationControllerが破棄されるのを防ぐためです。


  • getDelay関数では、各要素のアニメーションがトリガーされるまでの遅延を計算しました。これを実現するために、ミリ秒単位の期間を 6 で割って結果を概算し、それを位置で乗算しました。これが要素の位置 (この場合はインデックス) です。


Build メソッドでは、 AnimatedBuilderを返します。このアニメーション ビルダーでは、 Transform.translateウィジェットを返す_slideAnimationというウィジェット関数を返します。_slideAnimation _slideAnimationには、 offsetAnimation関数があります。


offsetAnimation関数は、 Transform.translateウィジェットで使用される Animation プロパティを返します。Transform.translate ウィジェットTransform.translate 、アニメーションの値を使用して子ウィジェットをアニメーション化します。

 @override Widget build(BuildContext context) { super.build(context); return AnimatedBuilder( animation: animationController, builder: (context, child){ return _slideAnimation(animationController); } ); } Widget _slideAnimation(Animation<double> animationController){ Animation<double> offsetAnimation(double offset, Animation<double> animationController) { return Tween<double>(begin: offset, end: 0.0).animate( CurvedAnimation( parent: animationController, curve: const Interval(0.0, 1.0, curve: Curves.ease), ), ); } return Transform.translate( offset: Offset( widget.horizontalOffset == 0.0 ? 0.0 : offsetAnimation(widget.horizontalOffset!, animationController).value, 0.0, ), child: widget.child, ); }


これはAnimateWidgetクラスの完全なコードです 👇🏽

 class AnimateWidget extends StatefulWidget { const AnimateWidget({super.key, required this.duration, required this.position, required this.horizontalOffset, required this.child, required this.controller, }); final Duration duration; final int position; final double? horizontalOffset; final Widget child; final PageController controller; @override State<AnimateWidget> createState() => _AnimateWidgetState(); } class _AnimateWidgetState extends State<AnimateWidget> with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin{ @override bool get wantKeepAlive => true; late AnimationController animationController; int currentPage = 0; Timer? _timer; @override void initState() { super.initState(); animationController = AnimationController(vsync: this, duration: widget.duration); _timer = Timer(getDelay(), animationController.forward); widget.controller.addListener(() { currentPage = widget.controller.page!.round(); if(currentPage == widget.controller.page){ _timer = Timer(getDelay(), animationController.forward); } }); } @override void dispose() { _timer?.cancel(); animationController.dispose(); super.dispose(); } Duration getDelay(){ var delayInMilliSec = widget.duration.inMilliseconds ~/ 6; int getStaggeredListDuration(){ return widget.position * delayInMilliSec; } return Duration(milliseconds: getStaggeredListDuration()); } @override Widget build(BuildContext context) { super.build(context); return AnimatedBuilder( animation: animationController, builder: (context, child){ return _slideAnimation(animationController); } ); } Widget _slideAnimation(Animation<double> animationController){ Animation<double> offsetAnimation(double offset, Animation<double> animationController) { return Tween<double>(begin: offset, end: 0.0).animate( CurvedAnimation( parent: animationController, curve: const Interval(0.0, 1.0, curve: Curves.ease), ), ); } return Transform.translate( offset: Offset( widget.horizontalOffset == 0.0 ? 0.0 : offsetAnimation(widget.horizontalOffset!, animationController).value, 0.0, ), child: widget.child, ); } }

次に、このAnimateWidgetクラスを列ウィジェットで使用するために、リストを返すtoStaggeredListという静的メソッドを持つクラスを作成します。このメソッドでは、 childrenリストを含む必要なすべてのパラメーターを渡します。 childrenパラメーターには、アニメーション化する要素のリストを渡します。


次に、子をマップし、各子をAnimateWidgetでラップします。

 class AnimateList{ static List<Widget>toStaggeredList({ required Duration duration, double? horizontalOffset, required PageController controller, required List<Widget>children, })=> children .asMap() .map((index, widget){ return MapEntry( index, AnimateWidget( duration: duration, position: index, horizontalOffset: horizontalOffset, controller: controller, child: widget, ) ); }) .values .toList(); }


AnimateWidgetでは、リスト内の各子要素AnimateList.toStaggeredList正常にアニメーション化するために必要なパラメータを渡します。AnimateList.toStaggeredList メソッドを使用して、作業中の 2 つのページにこれを実装できるようになりました。

 class Page1 extends StatelessWidget { const Page1({super.key, required this.pageController}); final PageController pageController; @override Widget build(BuildContext context) { return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( children: AnimateList.toStaggeredList( duration: const Duration(milliseconds: 375), controller: pageController, horizontalOffset: MediaQuery.of(context).size.width / 2, children: [ const EmptyCard(width: 250, height: 50,), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 180, height: 80,), ), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 270, height: 50,), ), const Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ EmptyCard(height: 50, width: 70), EmptyCard(height: 50, width: 70), EmptyCard(height: 50, width: 70), ], ), ), const EmptyCard(width: 250, height: 50,), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 180, height: 80,), ), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 270, height: 50,), ), const Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ EmptyCard(height: 50, width: 70), EmptyCard(height: 50, width: 70), EmptyCard(height: 50, width: 70), ], ), ), ], ), ), ); } } class Page2 extends StatelessWidget { const Page2({super.key, required this.pageController}); final PageController pageController; @override Widget build(BuildContext context) { return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( children: AnimateList.toStaggeredList( duration: const Duration(milliseconds: 375), controller: pageController, horizontalOffset: MediaQuery.of(context).size.width / 2, children: [ const EmptyCard(width: 220, height: 70,), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 300, height: 70,), ), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 200, height: 50,), ), const Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ EmptyCard(height: 70, width: 70), EmptyCard(height: 70, width: 70), ], ), ), const EmptyCard(width: 220, height: 70,), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 300, height: 70,), ), const Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ EmptyCard(height: 70, width: 70), EmptyCard(height: 70, width: 70), ], ), ), ], ), ), ); } }

列ウィジェットの子では、 AnimateList.toStaggeredListを渡し、列に表示されるウィジェットを含む必要なパラメータを渡します。これで、スワイプ時にトリガーされるずらしたアニメーションが正常に作成されました。完全なコードは、こちらで確認できます。


これが最終結果です:

結論

このチュートリアルはこれで終わりです。この時点で、マイクロインタラクションの概念とそれがユーザーエクスペリエンスに与える影響について説明しました。また、ページスワイプでトリガーされる列ウィジェット内のアイテムの段階的なアニメーションを構築するプロセスも説明しました。


プロジェクトのユーザー エクスペリエンスを向上させるためにアプリに追加できるマイクロ インタラクションの種類は非常に多く、Flutter でよりインタラクティブなアニメーションを構築して実験することができます。


この記事が役に立ったと思ったら、いいねやコメントを残してサポートしてください。また、関連記事をもっと読むために私をフォローすることもできます。

参考文献

Flutter スタッガードアニメーションパッケージ