Jetpack Composeで時間ベースのアニメーションを実装する

この記事をシェア

今回は、Jetpack Composeで物体の運動をシミュレーションしたり、波を動かしたりする方法を紹介します。ポイントは、withFrameMillisという関数でフレーム毎の時刻を取得するところです。

それでは、説明していきます。

時間ベースのアニメーション

物体の運動や波の振動などを画面上でシミュレーションするには、ある時刻における物体の位置や形状などを数式で表します。高校物理で習った、等速直線運動や等加速度運動など、物体の位置xを時刻tの関数で表していた、アレです。そして、画面更新のフレーム毎に、そのフレームの時刻を取得し、物体の位置や形状を計算して、描画します。

なお、Jetpack Composeにはアニメーションを実現するフレームワークがたくさん用意されていますが、これらは基本的に状態ベースです。複数の状態を定義し、各状態における変数の値を定義すると、それらの状態間の遷移をなめらかにアニメーションさせることができます。ですが、物理現象のシミュレーションのように、時間とともに変化する位置や形状をアニメーションさせるには向いていません。

withFrameMillisでフレーム毎の時刻を取得

Jetpack Composeでフレームの時刻を取得するには、withFrameMillisを使います。withFrameNanosという関数もありますが、画面の更新速度はせいぜい100Hzそこそこなので、多くの場合はミリ秒単位の値を返すwithFrameMillisのほうが使いやすいと思います。

suspend fun <R : Any?> withFrameMillis(onFrame: (frameTimeMillis: Long) -> R): R

この関数をLaunchEffectの中で繰り返し呼び出すことによって、フレームごとの時刻を取得することができます。

下記のコードでは、時刻の単位を秒に換算して、timeSecに代入しています。timeSecStateなので、timeSecを使って物体の位置や形状を表すコードを書けば、フレームごとに画面が更新されることになります。

var timeSec by remember { mutableStateOf(0f) }
LaunchedEffect(true) {
    while (true) {
        timeSec = withFrameMillis { it / 1000f }
    }
}

実装例

では、具体的な実装例を見ていきましょう。

等速直線運動

まずは、一定速度で動き続ける等速直線運動です。

円が画面からはみ出さないように、円の半径を考慮しつつ、画面の端に到達したら折り返す処理を書いているので、若干数式は複雑ですが、基本的には[時刻]×[速度]で現在位置を計算し、円を描画しているだけです。

@Composable
fun UniformLinearMotion(
    modifier: Modifier = Modifier,
) {
    var timeSec by remember { mutableStateOf(0f) }
    LaunchedEffect(true) {
        while (true) {
            timeSec = withFrameMillis { it / 1000f }
        }
    }
    Canvas(modifier = modifier) {
        val radius = 50f
        val speed = Offset(500f, 300f)
        // 円の中心が移動できる領域の大きさ
        val validSize = Size(size.width - radius * 2, size.height - radius * 2)
        // 時刻 timeSec におけるx, y座標を計算。端まで到達したら折り返す。
        val xTmp = (timeSec * speed.x) % (validSize.width * 2)
        val x = radius + if (xTmp > validSize.width) validSize.width * 2 - xTmp else xTmp
        val yTmp = (timeSec * speed.y) % (validSize.height * 2)
        val y = radius + if (yTmp > validSize.height) validSize.height * 2 - yTmp else yTmp
        drawCircle(
            color = Color.Red,
            radius = radius,
            center = Offset(x, y)
        )
    }
}

放物運動

次は放物運動です。

x方向は等速直線運動と同じで、y方向は2次関数で計算しています。

@Composable
fun ParabolicMotion(
    modifier: Modifier = Modifier,
) {
var timeSec by remember { mutableStateOf(0f) }
LaunchedEffect(true) {
    while (true) {
        timeSec = withFrameMillis { it / 1000f }
    }
}
    Canvas(modifier = modifier) {
        val radius = 50f
        val xSpeed = 500f
        val yAcceleration = 2000f
        // 円の中心が移動できる領域の大きさ
        val validSize = Size(size.width - radius * 2, size.height - radius * 2)
        // 時刻 timeSec におけるx, y座標を計算。端まで到達したら折り返す。
        val xTmp = (timeSec * xSpeed) % (validSize.width * 2)
        val x = radius + if (xTmp > validSize.width) validSize.width * 2 - xTmp else xTmp
        // 画面の上から下まで到達する時間
        val period = sqrt(validSize.height / yAcceleration)
        // timeSecを -period ~ period の範囲に正規化
        val timeForY = (timeSec % (period * 2)) - period
        val y = radius + yAcceleration * timeForY * timeForY
        drawCircle(
            color = Color.Blue,
            radius = radius,
            center = Offset(x, y)
        )
    }
}

最後は波です。

時刻 timeSecにおける波形をsin関数で表現し、点をつなげてPathにしています。sin波より下の部分を塗りつぶすことによって、波を表現しています。

@Composable
fun WaveMotion(
    modifier: Modifier = Modifier,
) {
    var timeSec by remember { mutableStateOf(0f) }
    LaunchedEffect(true) {
        while (true) {
            timeSec = withFrameMillis { it / 1000f }
        }
    }
    Canvas(modifier = modifier) {
        val nPoints = 40
        val amp = 100f
        val omega = 6f
        // sin波を描画するための点のリスト
        val points = List(nPoints) { i ->
            Offset(
                x = size.width * i / (nPoints - 1),
                y = size.height / 2 + amp * sin(omega * (i + timeSec))
            )
        }
        // sin波より下の部分を塗りつぶす
        val path = Path().apply {
            moveTo(points.first().x, points.first().y)
            points.forEach { lineTo(it.x, it.y) }
            lineTo(size.width, size.height)
            lineTo(0f, size.height)
            close()
        }
        drawPath(
            color = Color.Cyan,
            path = path,
        )
    }
}
この記事をシェア