リモート開発メインのソフトウェア開発企業のエンジニアブログです

Flutter の物理シミュレーションを理解する: ① Simulation の基本と FrictionSimulation

みなさんは GUI アプリケーションにおける物理シミュレーションについて考えたことはありますか? 物理シミュレーションというと高度な 3D ゲームのようなものを思い浮かべるかもしれませんが、普段使うスマートフォンの OS やアプリの UI にも、簡単な物理シミュレーションは使われています。

よい例が iOS の画面スクロールです。画面を指ではじくと、指を離した後もスクロールが続いて、徐々に減速していきます。これはつまり摩擦による減衰がシミュレートされているということです。その他、スクロールの終端に達した後に更に引っ張って離すと、すばやくスクロールは元の位置に戻ります。これはばねの挙動がシミュレーションされていると言えるでしょう。

このように、より自然な UI アニメーションを実現するためには、物理シミュレーションを取り入れることが有効です。そして、Flutter には物理シミュレーションを容易に実現するための仕組みが備わっています 🎉

これからこのシリーズ記事では、その仕組みを構成する Simulation 基底クラスとそのサブクラス群を利用して、Flutter でさまざまな物理シミュレーションによるアニメーションを実装する方法を紹介していきます。

まずは Simulation を使ってみる

Simulation 自体はアニメーションへの利用に限定されたものではないのですが、大体のアプリではアニメーションに利用することがほとんどかと思います。アニメーションで Simulation を使用する場合は、AnimationController と組み合わせます。実際に使い方を見てみましょう。

先に、 Simulation を利用しない単純なアニメーションを作っておきます。今回の例は、ボタンを押すとボックスが画面下から上に移動するというものです。

コードは以下のようになりました (重要な部分のみ抜粋しています) 。

class _SimulationScreenState extends State<SimulationScreen>
    with SingleTickerProviderStateMixin {
  // `AnimationController` をインスタンス化
  late final _animationController = AnimationController(
    vsync: this,
    duration: const Duration(milliseconds: 300),
  );

  @override
  void dispose() {
    _animationController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        children: [
          // AnimationController の値に応じてボックスの位置を変化させる
          ValueListenableBuilder(
            valueListenable: _animationController,
            builder: (context, value, child) {
              return Align(
                // value が 0 から 1 に変化するので、1 - value にすることでボックスが下から上に移動する
                alignment: FractionalOffset(0.5, 1 - value),
                child: Container(
                  width: 50,
                  height: 50,
                  color: Colors.blue,
                ),
              );
            },
          ),
          // コントロール用のボタン
          Align(
            alignment: Alignment.bottomRight,
            child: Padding(
              padding: const EdgeInsets.all(8),
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  // ボックスの位置をリセットするボタン
                  IconButton(
                    icon: const Icon(Icons.refresh),
                    onPressed: () {
                      _animationController.reset();
                    },
                  ),

                  const SizedBox(height: 8),

                  // ボックスのアニメーションを再生するボタン
                  IconButton(
                    icon: const Icon(Icons.play_arrow),
                    onPressed: () {
                      _animationController.forward();
                    },
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

それでは、ここに Simulation を取り入れてみましょう。Flutter に組み込まれている Simulation のサブクラスはいくつかありますが、まずは摩擦をシミュレートする FrictionSimulation を使ってみます。使い方は簡単で、シミュレーションごとに FrictionSimulation オブジェクトを作成して、AnimationControlleranimateWith メソッドに渡すだけです。

import 'package:flutter/physics.dart';

IconButton(
  icon: Icon(Icons.play_arrow),
  onPressed: () {
    // FrictionSimulation のインスタンスを作成
    // 細かいパラメータの意味は後で解説します
    final simulation = FrictionSimulation(
      0.1,
      0,
      2,
    );

    // AnimationController に Simulation を適用
    _animationController.animateWith(simulation);
  },
),

Simulation を使用する場合、基本的に AnimationControllerduration は無視されるので、一応消しておきます。

late final _animationController = AnimationController(vsync: this);

実行してみましょう。

FrictionSimulation によって、ボックスが徐々に減速し、停止するという挙動になりました! これが Simulation の基本的な使い方です。

FrictionSimulation のパラメータ

FrictionSimulation のデフォルトコンストラクタは以下のように定義されています。

FrictionSimulation(
  double drag,
  double position,
  double velocity,
  {Tolerance tolerance = Tolerance.defaultTolerance,
  double constantDeceleration = 0}
)

パラメータが色々あってややこしいですね。そんな時は実際に値を変えながら色々試してみると理解しやすくなることが多いです。ということで簡単にパラメータを変えて挙動を確認できる Playground を DartPad で作りました 🚀

https://dartpad.dev/?id=60755b8332d7b23f46149afd0559f305

Webブラウザで試せます。

ここからは細かいパラメータの働きについて解説していきます。

drag

誤解を恐れずざっくり説明すると、「摩擦による減衰の強さ」を表す値です。0 と 1 の間で指定し、ややこしいですがこの値が大きいほど減衰は弱く、また小さいほど減衰は強くかかります。「滑りやすさ」とも言えるかもしれません。

position

シミュレーション開始時の初期位置です。

ここで、物理シミュレーションを扱っていく中で頻出の概念を押さえておいてほしいのですが、この初期位置、単位は「任意」です。任意ってどういうこと?と思うかもしれませんが、言い換えると「あなたが自由に決めていいけど、関連する場所では同じ単位を使ってね」ということです。

例えば、高さ 900 dp (論理ピクセル) の画面があるとします。この画面の上端から下端に何かオブジェクトが移動する時、これをどのように表せるでしょうか。素直に考えれば「0 dp → 900 dp」ですが、もう一つ考え方があります。それは「0 → 1」というものです。前者で画面の中央は 450 dp ですし、後者では 0.5 です。すなわち前者の単位は論理ピクセル、後者の単位は「画面の高さ」ということになります。

どのように単位を決定するかは場合によります。例えば AnimationController で値は 0 から 1 に遷移するので、それに合わせた方が都合がよかろうと考え、前述の例では画面の高さを単位とし、Align ウィジェットと FractionalOffset オブジェクトで実際のボックスの位置を操作しています。

重要なのは、仮に位置の単位を画面の高さと決めた場合、同じシミュレーションの中の全ての場所で、位置の単位は画面の高さとして扱わなければならないということです。ちょっと実感として分かりにくい部分ではありますが、もし物理シミュレーションで思った通りにいかないことがあれば、単位が間違っていないかを確認するとよいでしょう。

velocity

シミュレーション開始時の初速度です。単位はこちらも任意ですが、速度とは単位時間あたりの移動量 (変位) です。AnimationController の時間単位は秒と決まっているので、位置の単位が決まっていれば自ずと速度の単位も決まります。

前述の例では 2 を指定していますが、位置の単位が画面の高さであることを踏まえると、これは (摩擦が無ければ) 1秒間にちょうど2画面分進む速度であることが分かるでしょう。

tolerance

オプショナルですしあまり自分で設定することは無いと思うのですが、簡単に説明しておきます。

物理シミュレーションでは、最終的に変化値が一定の値に「収束」する、ということがよくあります。例えば摩擦では最終的に対象の物体は動きを止めて静止します。この計算には通常、指数関数や対数関数が用いられますが、例として簡易的な減衰を表す次の関数を見てみましょう。

$$
x(t) = 1 {-} e^{-t}
$$

この関数の極限は

$$
\lim_{t \to \infty} x(t) = 1
$$

となりますが、厳密には \(x = 0\) に到達することはありません。つまり、シミュレーションが永遠に続いてしまうのです。それを避けるために「値がこの範囲内だったらシミュレーションを終了とする」という許容誤差を定義する必要があるわけですが、それが tolerance です。

constantDeceleration

これについては私もあまり詳しく理解していません。おそらく iOS や macOS でのスクロールの挙動を再現するためのもののようですが、普通は 0 でいいでしょう。

Moba Pro

FrictionSimulation.through

FrictionSimulation には through というもう一つのコンストラクタが用意されています。

FrictionSimulation.through(
  double startPosition,
  double endPosition,
  double startVelocity,
  double endVelocity
)

これは開始位置 startPosition、終了位置 endPosition、初速度 startVelocity、終速度 endVelocity を指定するとそれに応じた摩擦をかけてくれるというものです。いくつかパラメータの関係性に制限があり、

  • startVelocityendVelocity の符号は同じでなければならない
  • startVelocity の絶対値は endVelocity の絶対値と同じかそれより大きくなければならない
  • endPosition - startPosition の符号は startVelocity の符号と同じでなければならない

の 3 つの条件を全て満たす必要があります。

ただ一つ注意点がありまして、FrictionSimulation.throughendVelocity0.0 に設定すると、シミュレーションが永遠に終わらなくなります。理由は FrictionSimulation.through では endVelocity (の絶対値) が tolerance として設定されるからです。なので 0.0 を設定したい場合は、代わりに 0.001-0.001 のような、絶対値が 0.0 ではないが十分に小さい値を設定してあげましょう。

まとめ

今回の記事では SimulationAnimationController と組み合わせる基本的な使い方と、FrictionSimulation の具体的な使い方を解説しました。次回は重力をシミュレートする GravitySimulation について解説する予定です。

参考

余談

コードを読むと実際のロジックが分かります。具体的には、時間 \(t\) における位置 \(x(t)\) は以下のように表されます。

$$
x(t) = x_0 + \frac{v_0(c_x^t {-} 1)}{\ln c_x} {-} \frac{a}{2} t^2
$$

ここで \(x_0\) は初期位置 (position)、\(v_0\) は初速度 (velocity)、\(c_x\) は抗力係数 (drag)、\(a\) は constantDeceleration です。

同じく、時間 \(t\) における速度 \(v(t)\) は以下のようになっています。

$$
v(t) = v_0 c_x^t {-} at
$$

ところで、drag はドキュメントでは “the fluid drag coefficient \(c_x\)”、つまり「抗力係数」であるとされています。そもそもの話ですが、FrictionSimulation がシミュレートするのは摩擦の中でも「流体摩擦」であるようです。

物理学における一般的な抗力や抗力係数については、例えば Wikipedia の以下のページなどに説明があります。

抗力 – Wikipedia

……が、drag の性質はそれとは全く異なりますよね。シンプルなシミュレーションとしてこのようなパラメータがあるのは理解できますが、なぜ「抗力係数」と言い切っているのでしょうか……。有識者の方がいたらご教示いただけると助かります。

← 前の投稿

PythonでChatGPT APIを使ってみる 第3回 LangChainの簡単な使用方法

次の投稿 →

WSL内で起動したコンテナから、同じくWSL内で起動したAPIサーバにアクセスする方法

コメントを残す