スタッガード アニメーションは、連続したアニメーションまたは重なり合ったアニメーションで構成されます。アニメーションは、1 つの変更が次の変更の後に発生する完全に連続的なものである場合もあれば、部分的または完全に重なり合う場合もあります。アニメーションが連続的である場合、各要素の開始時間の間にわずかな遅延があり、要素は連続的にアニメーション化されます。これにより、カスケード効果または波及効果が生まれ、アニメーションは一度にではなく段階的に要素間を移動しているように見えます。
スタッガードアニメーションは、ユーザーをインターフェースを通じてガイドする微妙でインタラクティブなフィードバックを提供することでユーザーエクスペリエンスを向上させるため、マイクロインタラクションの一種と見なされます。Flutter では、暗黙的または明示的なアニメーションを使用して微妙なアニメーションを作成することで、マイクロインタラクションを構築できます。
コンテキストでは、暗黙的なアニメーションはアニメーションの詳細が抽象化されているため、シンプルで使いやすいように設計されていますが、明示的なアニメーションはアニメーション プロセスを完全に制御できるため、複雑なアニメーションに適しています。これは、アニメーションをより細かく制御する必要がある場合に特に使用されます。
この記事では、マイクロインタラクションの概念を詳しく説明し、マイクロインタラクションのユースケースとして、明示的なアニメーションを使用して、列ウィジェットに含まれる子要素をアニメーション化する段階的なアニメーションを作成します。
優れた製品とは、機能と詳細の両方が優れている製品です。機能は人々を製品に引き付けますが、詳細が人々を惹きつけます。これらの詳細が、あなたのアプリを他のアプリより際立たせるものです。マイクロインタラクションを使用すると、ユーザーに楽しいフィードバックを提供することで、これらの詳細を作成できます。
マイクロインタラクションの概念は、小さな詳細が全体的なユーザーエクスペリエンスに大きな影響を与える可能性があるという考えに基づいています。マイクロインタラクションは、フィードバックやアクションの結果を伝えるなどの重要な機能を果たすために使用できます。マイクロインタラクションの例は次のとおりです。
以下では、マイクロインタラクションの実際のアプリケーションを見ることができます。これらの微妙なアニメーションは、ユーザーエクスペリエンスを向上させるために Flutter で作成されています。👇🏽
これらのアプリのデザイン参考資料はDribbbleから入手しました
この例では、ページがスワイプされたときに列ウィジェット内の子をアニメーション化する、ずらしたアニメーションを作成します。この場合、アニメーションの実行方法を完全に制御する必要があるため、明示的なアニメーション アプローチを使用して構築されます。
完成したアニメーションは次のようになります👇🏽
このチュートリアルを最大限に活用するには、次のものが必要です。
プロジェクトの前提条件をすべて確認したら、早速始めましょう。
まず、これら 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, ), ), ) ); } }
上記のコード スニペットで使用されているSmoothPageIndicator
、 pub.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 でよりインタラクティブなアニメーションを構築して実験することができます。
この記事が役に立ったと思ったら、いいねやコメントを残してサポートしてください。また、関連記事をもっと読むために私をフォローすることもできます。