[読書会] physics によるウィジェットのアニメーション化

(このページは、docs.flutter.dev(https://docs.flutter.dev/cookbook/animation/physics-simulation)のリファレンスブログです)

 アニメーションでは、physics を利用してアプリの操作をよりリアルでインタラクティブなものにできます。
(例えば、ウィジェットがバネで引っ張られている様な動きや、重力で落下する動きの再現)

 このガイドでは、ウィジェットをドラッグして離した後、そのウィジェットが中央に戻る際にバネの動きをシミュレートする方法について説明しています。物理シミュレーションを使用することで、ユーザが直感的に感じられる自然な動きを簡単に実装することができます。

1.AnimationController の設定

 以下の Flutterコードは、DraggableCard という StatefulWidget を使用して、物理シミュレーションを設定する初期段階を示しています。このサンプルでは、AnimationController を用いて、ウィジェットがアニメーションされる際の動作を制御します。

  • SingleTickerProviderStateMixin:
     _DraggableCardStateクラスが TickerProvider として機能し、アニメーションコントローラが効率的に画面のフレームを更新できるようにします。
  • AnimationController:
     アニメーションの進行を制御するオブジェクト。
     vsync に this を渡すことで、アニメーションが不要な際にリソースを節約します。
  • initState():
     クラスが初期化される際に、AnimationController が構築されます。
  • dispose():
     クラスが破棄される際に、AnimationController を破棄してメモリリークを防ぎます。

 このステップにより、ウィジェットが physics を使用してアニメーションされるための土台が完成します。

AnimationController の設定(例):
import 'package:flutter/material.dart';

//エントリーポイント:
//( アプリを起動し、PhysicsCardDragDemoを表示 )
void main() {
  runApp(const MaterialApp(home: PhysicsCardDragDemo()));
}

//PhysicsCardDragDemoクラス:
//( Scaffoldを使って基本的な画面を構成 )
class PhysicsCardDragDemo extends StatelessWidget {
  const PhysicsCardDragDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: const DraggableCard(
        //ウィジェット内にFlutterのロゴを含むカードを表示
        child: FlutterLogo(
          size: 128, //ロゴのサイズ
        ),
      ),
    );
  }
}

//DraggableCardクラス:
//( ウィジェットをドラッグ可能にするためのStatefulWidget )
class DraggableCard extends StatefulWidget {
  const DraggableCard({required this.child, super.key});

  final Widget child; //子ウィジェット

  @override
  State<DraggableCard> createState() => _DraggableCardState();
}

//_DraggableCardStateクラス:
//( アニメーション状態管理 )
class _DraggableCardState extends State<DraggableCard> with SingleTickerProviderStateMixin { //TickerProviderを追加
  late AnimationController _controller; //アニメーション管理用コントローラ

  @override
  void initState() {
    super.initState();
    //AnimationControllerの初期化:
    //( vsyncにはthisを設定し、フレーム更新を管理 )
    _controller = AnimationController(
      vsync: this, 
      duration: const Duration(seconds: 1)
    );
  }

  @override
  void dispose() {
    _controller.dispose(); //コントローラーを破棄し、リソースを解放
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Align(
      child: Card( //カードウィジェットを表示
        child: widget.child, //DraggableCardで指定した子ウィジェットを表示
      ),
    );
  }
}

2.GestureDetector によるウィジェットの移動

 Flutterアプリで GestureDetector を用いてウィジェットを動かす方法について解説しています。
(具体的には、DraggableCard ウィジェットを実装し、GestureDetector で、ドラッグ位置にウィジェットを移動させます。)

(1)Alignmentフィールドの追加

 Alignment フィールド(_dragAlignment)を追加し、ドラッグ中のウィジェットの位置を保持します。

Alignmentフィールドの追加: Alignmentフィールド(_dragAlignment)を追加し、ドラッグ中のウィジェットの位置を保持します。
//ドラッグ状態とアニメーションを管理するStateクラス
class _DraggableCardState extends State<DraggableCard>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller; // アニメーションコントローラー

  //カードの現在の位置を保持するアライメント。初期位置は中央
  Alignment _dragAlignment = Alignment.center;

(2)GestureDetectorの追加

 GestureDetectorを使用して、onPanDownonPanUpdateonPanEnd のコールバックを実装します。
これらのコールバックでドラッグの開始、更新、終了を検知します。

GestureDetectorの追加: GestureDetectorを使用して、onPanDown、onPanUpdate、onPanEnd のコールバックを実装します。 これらのコールバックでドラッグの開始、更新、終了を検知します。
@override
Widget build(BuildContext context) {
  //ウィジェットのサイズを取得:
  //( MediaQueryを使用してウィジェットの画面サイズを取得し、
  //  ドラッグのピクセル数をAlignで使用する座標に変換します。 )
  var size = MediaQuery.of(context).size; 
  
  return GestureDetector(
    //ドラッグ開始時に呼ばれる
    onPanDown: (details) {}, 
    //ドラッグ中の位置を更新
    onPanUpdate: (details) { 
      setState(() {
        _dragAlignment += Alignment(
          //横方向ドラッグ量をアライメントに変換
          details.delta.dx / (size.width / 2),
          //縦方向ドラッグ量をアライメントに変換
          details.delta.dy / (size.height / 2),
        );
      });
    },
    //ドラッグ終了時に呼ばれる
    onPanEnd: (details) {}, 
    child: Align(
      alignment: _dragAlignment, //ドラッグ中のアライメントで位置を調整
      child: Card(
        child: widget.child,
      ),
    ),
  );
}

3.ウィジェットのアニメーション化

 ウィジェットをドラッグした後に中央にスプリングのように戻る動作を実装するために、アニメーションを追加します。

(1)Animationフィールドと_runAnimationメソッドを追加

  animation フィールドを追加し、_runAnimation メソッドを定義して、ドラッグ位置(_dragAlignment)から中央(Alignment.center)に補間するTweenを設定します。

Animationフィールドの追加(例):
class _DraggableCardState extends State<DraggableCard> with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  //アニメーションフィールドを追加
  late Animation<Alignment> _animation;
  //ドラッグ位置のアライメント
  Alignment _dragAlignment = Alignment.center;
_runAnimationメソッドの定義(例):
void _runAnimation() {
  _animation = _controller.drive(
    AlignmentTween(
      begin: _dragAlignment, // 現在のドラッグ位置
      end: Alignment.center, // 中央の位置
    ),
  );
  _controller.reset(); // コントローラをリセット
  _controller.forward(); // アニメーションを開始
}

(2)AnimationController の addListener を設定

 initState メソッドで _controller を初期化し、アニメーションの addListener を追加して _dragAlignment の更新を行います。

AnimationController の addListener を設定(例): initState メソッドで _controller を初期化し、アニメーションの addListener を追加して _dragAlignment の更新を行います。
@override
void initState() {
  super.initState();
  _controller = AnimationController(
    //1秒のアニメーション
    vsync: this, duration: const Duration(seconds: 1)
  ); 
  _controller.addListener(() {
    setState(() {
      _dragAlignment = _animation.value; //アニメーションの値で更新
    });
  });
}

(3)Alignウィジェットのalignmentプロパティを使用

 _dragAlignment を使用してウィジェットの配置を管理します。

Alignウィジェットのalignmentプロパティを使用(例):
child: Align(
  alignment: _dragAlignment, //カードの現在位置を保持するアライメントを設定
  child: Card(
    child: widget.child,
  ),
),

(4)GestureDetectorの更新

 onPanEnd _runAnimation を呼び出し、アニメーションを実行するようにします。

GestureDetectorの更新(例):
return GestureDetector(
  onPanDown: (details) {
    _controller.stop(); // ドラッグ時にアニメーションを停止
  },
  onPanUpdate: (details) {
    // ドラッグ中の処理
  },
  onPanEnd: (details) {
    _runAnimation(); // ドラッグ終了時にアニメーション開始
  },
  child: Align( // アラインを使用

 このアプローチでは、ユーザがウィジェットをドラッグし、離した後にスムーズに中央に戻るアニメーションを作成します。
スプリングシミュレーションによって、ウィジェットは物理的な動作のように見え、ユーザー体験が向上します。

4.跳ね返り動作のための速度計算

 ウィジェットをドラッグした後に、スプリングのような動きで元の位置に戻すためには、ドラッグが終了した時の速度を計算してシミュレーションに反映させる必要があります。以下に説明します。

(1)physics パッケージをインポート

 シミュレーションに必要なクラスを使用するため、physics パッケージをインポートします。

physics パッケージをインポート:
import 'package:flutter/physics.dart';

(2)速度を計算する

 onPanEnd コールバックによって提供される DragEndDetails オブジェクトを使用して、ウィジェットが画面に接触を終えた時のポインタの速度を取得します。この速度は「ピクセル毎秒」で表されますが、Align ウィジェット座標値([-1.0, -1.0]から[1.0, 1.0]の範囲)を使用します。そのため、画面サイズを用いて、ピクセル単位の速度をこの座標値に変換します

(3)SpringSimulation を設定

 AnimationControlle animateWith()メソッド を使用して、SpringSimulation 設定します。
(このシミュレーションは、ウィジェットが自然な動きで元の位置に戻るために必要です。)

AnimationControllerのanimateWith()メソッドを使用して、SpringSimulationを設定します。このシミュレーションは、ウィジェットが自然な動きで元の位置に戻るために必要です。
  //SpringSimulation 実行に必要な値を計算して実行する関数
  void _runAnimation(Offset pixelsPerSecond, Size size) {
    //animation を現在位置から中央位置まで作成
    _animation = _controller.drive(
      AlignmentTween(
        begin: _dragAlignment,
        end: Alignment.center,
      ),
    );
    
    //アニメーションの速度を単位区間に対して計算
    final unitsPerSecondX = pixelsPerSecond.dx / size.width;
    final unitsPerSecondY = pixelsPerSecond.dy / size.height;
    final unitsPerSecond = Offset(unitsPerSecondX, unitsPerSecondY);
    final unitVelocity = unitsPerSecond.distance; // 単位速度

    //スプリングの設定
    const spring = SpringDescription(
      mass: 30, //質量
      stiffness: 1, //剛性
      damping: 1, //減衰
    );

    //SpringSimulation: アニメーションを実行
    final simulation = SpringSimulation(spring, 0, 1, -unitVelocity);
    _controller.animateWith(simulation);
  }

(4)onPanEndコールバックでのアニメーション実行

 onPanEnd コールバック内で _runAnimation を呼び出して、速度とサイズを渡しシミュレーションを開始します。

onPanEndコールバックでのアニメーション実行:
//ドラッグ終了時にスプリングシミュレーションを実行
onPanEnd: (details) {
  _runAnimation(details.velocity.pixelsPerSecond, size);
},

(5)まとめ

  • ドラッグが終わった後の速度を基に、自然な戻り動作をシミュレーションするために SpringSimulation を使用します。
  • 速度を座標値に変換することで、Align ウィジェットが適切に動作するようにします。
  • AnimationController animateWith()メソッドを使うことで、シミュレーションをコントローラに渡して実行します。

(注意)このアニメーションコントローラはシミュレーションを使用しているため、duration引数は不要です。

5.サンプルコード

[読書会] physics によるウィジェットのアニメーション化(例):
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';

// アプリのエントリーポイント
void main() {
  runApp(const MaterialApp(home: PhysicsCardDragDemo()));
}

// メインのデモ用クラス。Scaffoldで画面を構成
class PhysicsCardDragDemo extends StatelessWidget {
  const PhysicsCardDragDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(), // アプリバーを表示
      body: const DraggableCard( // カードをドラッグ可能にするウィジェット
        child: FlutterLogo( // 表示するロゴ
          size: 128, // ロゴのサイズ
        ),
      ),
    );
  }
}

// ドラッグ可能なカードウィジェットのクラス
class DraggableCard extends StatefulWidget {
  const DraggableCard({required this.child, super.key});

  final Widget child; // カード内に表示するウィジェット

  @override
  State<DraggableCard> createState() => _DraggableCardState();
}

// ドラッグ状態とアニメーションを管理するStateクラス
class _DraggableCardState extends State<DraggableCard>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller; // アニメーションコントローラー

  // カードの現在の位置を保持するアライメント。初期位置は中央
  Alignment _dragAlignment = Alignment.center;

  late Animation<Alignment> _animation; // アニメーション用のアライメント

  // スプリングシミュレーションを計算して実行する関数
  void _runAnimation(Offset pixelsPerSecond, Size size) {
    // アニメーションを現在位置から中央位置まで作成
    _animation = _controller.drive(
      AlignmentTween(
        begin: _dragAlignment,
        end: Alignment.center,
      ),
    );
    
    // アニメーションの速度を単位区間に対して計算
    final unitsPerSecondX = pixelsPerSecond.dx / size.width;
    final unitsPerSecondY = pixelsPerSecond.dy / size.height;
    final unitsPerSecond = Offset(unitsPerSecondX, unitsPerSecondY);
    final unitVelocity = unitsPerSecond.distance; // 単位速度

    // スプリングの設定
    const spring = SpringDescription(
      mass: 30, // 質量
      stiffness: 1, // 剛性
      damping: 1, // 減衰
    );

    // スプリングシミュレーションを使用してアニメーションを実行
    final simulation = SpringSimulation(spring, 0, 1, -unitVelocity);
    _controller.animateWith(simulation);
  }

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(vsync: this); // アニメーションコントローラーの初期化

    // コントローラーのリスナーを設定して、位置を更新
    _controller.addListener(() {
      setState(() {
        _dragAlignment = _animation.value;
      });
    });
  }

  @override
  void dispose() {
    _controller.dispose(); // コントローラーを破棄してリソースを解放
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final size = MediaQuery.of(context).size; // 画面サイズを取得
    return GestureDetector( // ジェスチャーを検出
      onPanDown: (details) {
        _controller.stop(); // ユーザーがドラッグを開始したらアニメーションを停止
      },
      onPanUpdate: (details) {
        // ドラッグ中に位置を更新
        setState(() {
          _dragAlignment += Alignment(
            details.delta.dx / (size.width / 2), // 横方向の移動量を計算
            details.delta.dy / (size.height / 2), // 縦方向の移動量を計算
          );
        });
      },
      onPanEnd: (details) {
        // ドラッグ終了時にスプリングシミュレーションを実行
        _runAnimation(details.velocity.pixelsPerSecond, size);
      },
      child: Align( // カードを現在のアライメントに配置
        alignment: _dragAlignment,
        child: Card( // カードウィジェットを表示
          child: widget.child, // 子ウィジェットを含める
        ),
      ),
    );
  }
}

コメントを残す