今回は、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 }
}
}
実装例
では、具体的な実装例を見ていきましょう。
等速直線運動
まずは、一定速度で動き続ける等速直線運動です。
円が画面からはみ出さないように、円の半径を考慮しつつ、画面の端に到達したら折り返す処理を書いているので、若干数式は複雑ですが、基本的には[時刻]×[速度]で現在位置を計算し、円を描画しているだけです。
@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,
)
}
}