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に代入しています。timeSecはStateなので、timeSecを使って物体の位置や形状を表すコードを書けば、フレームごとに画面が更新されることになります。
var timeSec by remember { mutableStateOf(0f) }LaunchedEffect(true) { while (true) { timeSec = withFrameMillis { it / 1000f } }}実装例
では、具体的な実装例を見ていきましょう。
等速直線運動
まずは、一定速度で動き続ける等速直線運動です。

円が画面からはみ出さないように、円の半径を考慮しつつ、画面の端に到達したら折り返す処理を書いているので、若干数式は複雑ですが、基本的には[時刻]×[速度]で現在位置を計算し、円を描画しているだけです。
@Composablefun 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次関数で計算しています。
@Composablefun 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波より下の部分を塗りつぶすことによって、波を表現しています。
@Composablefun 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, ) }}