Jetpack compose でカードが回転するLayoutを作ってみる

タイトル通りです。某バーコード決済アプリのTop画面にあるカードレイアウトのイメージです。
Composeで思いのほか楽に再現できたので投下

<img width="240" alt="回転するカード" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/700755/51a3efad-d940-4a14-e69e-afafffd13bfc.gif">

#### コード

Preview使えばすぐに見れます。

https://github.com/kkhouse/AnimationExplore/blob/main/app/src/main/java/com/example/animationexploration/PayPayCardLayout.kt

#### サクッと説明

`Modifier.graphicsLayer` で縦への移動、回転
`Modifier.zIndex` で Z軸の入れ替え

これだけで雑に作れました。
`cameraDistance`はデフォルトだと、回転する際に画像が大きくなりすぎるので設定してます。

```kotlin
Box(
    modifier = Modifier
        .graphicsLayer(
            translationY = translationY,
            rotationX = rotationX,
            cameraDistance = cameraDistance,
        )
        .background(Color.White)
        .size(width = 360.dp, height = 280.dp)
        .zIndex(cardZIndex)
        .clickable { onCardTap() }
) {
    Text(text = "Card")
}
```


アニメーション値は以下にて制御
全部単一のFloat値なので animate*asStateの`animateFloatAsState` を使ってます。

animationSpecは全部tweenです。　
ほぼ単純に値をしていするだけでできてしまってますね。

```kotlin
enum class CardZIndex(val value: Float) {
    BACK(-1f), FRONT(1f);
}
fun CardZIndex.switch() : CardZIndex {
    return when(this) {
        CardZIndex.BACK -> CardZIndex.FRONT
        CardZIndex.FRONT -> CardZIndex.BACK
    }
}

enum class TranslationY(val value : Float) {
    Default(0f), Top(-300f)
}
fun TranslationY.transit(): TranslationY {
    return when(this) {
        TranslationY.Default -> TranslationY.Top
        TranslationY.Top -> TranslationY.Default
    }
}

enum class RotationX(val value: Float) {
    Front(0f), Back(-180f)
}
fun RotationX.rotate(): RotationX {
    return when(this) {
        RotationX.Front -> RotationX.Back
        RotationX.Back -> RotationX.Front
    }
}

const val DURATION_MS = 1000

@Composable
fun PayPayCardLayout() {
    var zIndex by remember { mutableStateOf(CardZIndex.BACK) }
    var translationY by remember { mutableStateOf(TranslationY.Default) }
    var rotationX by remember { mutableStateOf(RotationX.Front) }

    val animateZIndex by animateFloatAsState(
        targetValue = zIndex.value,
        animationSpec = tween(
            durationMillis = DURATION_MS,
            easing = LinearEasing
        )
    )
    val animateTransitionY by animateFloatAsState(
        targetValue = translationY.value,
        animationSpec = repeatable(
            iterations = 1,
            animation = tween(
                durationMillis = DURATION_MS/2,
                easing = LinearEasing
            ),
            repeatMode = RepeatMode.Reverse
        )
//        keyframes { // TODO keyframesでもうちょいましなやりかたがありそう
//            durationMillis = DURATION_MS
//            TranslationY.Top.value to (DURATION_MS/2) // アニメーション時間の真ん中でTopに移動
//        }
    )
    val animateRotationX by animateFloatAsState(
        targetValue = rotationX.value,
        animationSpec = tween(
            durationMillis = DURATION_MS,
            easing = LinearEasing
        )
    )

    val scope = rememberCoroutineScope()

    PayPayCard(
        cardZIndex = animateZIndex,
        translationY = animateTransitionY,
        rotationX = animateRotationX,
        onCardTap = {
            zIndex = zIndex.switch()
            rotationX = rotationX.rotate()
            translationY = translationY.transit()
            scope.launch {// translationYをリバースしてもどす
                delay((DURATION_MS/2).toLong())
                translationY = translationY.transit()
            }
        }
    )
}

```

translationYのリバースアニメーションを無理やり戻しているので、ここはもっといい書き方がありそう。


#### おわり

graphicsLayerはいかがとても参考になりました。業務系なら大概にアニメーションは簡単にできそうですね。

[Have Fun With Jetpack Compose GraphicsLayer Modifier](https://medium.com/mobile-app-development-publication/have-fun-with-jetpack-compose-graphicslayer-modifier-e39c12a4791f)

composeすごい。