【Jetpack Compose】お気に入りボタンのタップアニメーションの作り方

この記事は[and factory.inc Advent Calendar 2021](https://qiita.com/advent-calendar/2021/andfactory) 20日目の記事です。
昨日は @k_shinn さんの [【Android】画面分割やカットアウトに負けない画面サイズの測り方](https://qiita.com/k_shinn/items/e6c12d459bec5af36fae) でした。

## はじめに

ツイッター等アプリでよく見る『お気に入り、いいねボタンをタップしたときのアニメーション』をjetpack composeで1ステップずつ段階を踏んで作ってみたいと思います。

![record-211219154544.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/193770/8bccfe88-492b-a005-3934-fd421cb423cd.gif)


## ドキュメント類

https://developer.android.com/jetpack/compose/animation?hl=ja

はおさえておきたいドキュメントです。
少しずつ理解を広げられます。

私も基本的には上記確認しながらボタンを作りました。

## ステップ1 ~ボタンを作る~

お気に入りという状態のモデルクラスとお気に入りボタン自体のComposable関数を作ってみます。
大体の場合isFavorite:Booleanの2値で管理されているのではないかなと思っています。
ボタンとして考えることは大枠として以下のような感じです。

- お気に入り状態(FavoriteButtonState)は親のComposable関数から渡してもらう
- 渡された状態によって★か☆を表示する
 - ベクターリソースを使っています。pngなどの画像リソースでも大丈夫です
- タップしたときにイベント(onToggle())を発火する

ついでにPreviewも作っておきます。これがあるとデバッグもはかどります。

```kotlin

data class FavoriteButtonState(val isFavorite: Boolean)

@Composable
fun FavoriteButton(
    modifier: Modifier,
    buttonState: FavoriteButtonState,
    onToggle: () -> Unit,
) {
    IconButton(
        onClick = { onToggle() },
        modifier = modifier,
    ) {
        Image(
            imageVector = ImageVector.Companion.vectorResource(
                if (buttonState.isFavorite) {
                    R.drawable.ic_star_off
                } else {
                    R.drawable.ic_star_on
                }
            ),
            contentDescription = "favorite",
        )
    }
}

@Preview
@Composable
fun FavoriteButtonPreview() {
    MyComposeTheme {
        var buttonState by remember {
            mutableStateOf(FavoriteButtonState(isFavorite = false))
        }
        FavoriteButton(
            modifier = Modifier,
            buttonState = buttonState,
            onToggle = {
                buttonState = if (buttonState.isFavorite) {
                    buttonState.copy(isFavorite = false)
                } else {
                    buttonState.copy(isFavorite = true)
                }
            })
    }
}
```

### ここまでのgif

おめでとうございます！とりあえず何もアニメーションしないON/OFFが切り替わるだけのボタンが作れました🎉
![record-211219143750.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/193770/f4a8ce7f-bbe4-f0ad-68d1-99db4d9cd93c.gif)

## ステップ2 ~アニメーションを考える~

アニメーションは開始点〜終了点を決めてその値の間を段階的に変化させることで実現します。
例えば大きさが50dp->80.dpに変わるなどです

この場合は `animateDpAsState` などを使ってアニメーションできたりします。
この `animate*AsState` はDp以外にもFloatやIntなど色々ありますので確認してみてください。

ただ、今回は開始と終了のサイズが同じなのでアニメーションが効かない問題が出てきます。

```kotlin
val size: Dp by animateDpAsState(
    targetValue = if (buttonState.isFavorite) 50.dp else 80.dp,
// こうしても開始と終了点が同じのためアニメーションされない
//    targetValue = if (buttonState.isFavorite) 50.dp else 50.dp,
//    animationSpec = keyframes {
//        50.dp at 0
//        80.dp at 100
//        50.dp at 300
//    },
)
```

### ここまでのgif

惜しい！あと一歩！という感じですね。

![record-211219145307.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/193770/93677c62-ff17-87b7-8055-0f4639c64cac.gif)

## ステップ3 ~updateTransitionを使ったアニメーション~

updateTransitionとkeyFrameを組み合わせると開始〜終了値が同じでも間の値を変化させてアニメーションすることができます。

[updateTransitionとは？](https://developer.android.com/jetpack/compose/animation?hl=ja#updateTransition)　原文そのまま
> Transition は、1 つ以上のアニメーションを子として管理し、複数の状態間で同時に実行します。

ということでtransitionを使ってsizeを変化させてみたいと思います。

```kotlin
@Composable
fun FavoriteButton(
    modifier: Modifier,
    buttonState: FavoriteButtonState,
    onToggle: () -> Unit,
) {
    val transition = updateTransition(buttonState, label = null)
    val size by transition.animateDp(label = "", transitionSpec = {
        keyframes {
            64.dp at 0 with FastOutSlowInEasing
            96.dp at 100 with FastOutSlowInEasing
            64.dp at 300 with FastOutSlowInEasing
        }
    }) { _state ->
        // if文使う必要も特にないので直接dp指定しても大丈夫だと思います。
        if (_state.isFavorite) 64.dp else 64.dp
    }
    IconButton(
        onClick = { onToggle() },
        modifier = modifier.size(size),
    ) {
        // 略
    }
}
```

ポイントはkeyFramesで、アニメーションがそれぞれ0ms, 100ms, 300msのときに何dpになっているか、その時のEasingを設定しています。
Easingはオプショナル的なものなのでなくても良いですが、あるとより見栄えが良くなると思います。

### ここまでのgif

おめでとうございます！タップアニメーションが作れました！⭐️⭐️⭐️

![record-211219152556.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/193770/6fb96383-8a33-f30a-4fee-e5673fa3764e.gif)


## sizeとscaleのアニメーションの違い

sizeの代わりにscaleを使ってアニメーションした場合はどうなるでしょうか？

```kotlin
val scale by transition.animateFloat(label = "", transitionSpec = {
    keyframes {
        0.7f at 0 with FastOutSlowInEasing
        1.0f at 100 with FastOutSlowInEasing
        0.7f at 300 with FastOutSlowInEasing
    }
}) {_state ->
    // if文使う必要も特にないので直接scale値指定しても大丈夫だと思います。
    if (_state.isFavorite) 0.7f else 0.7f
}
```

sizeはその名の通りViewの大きさが変わりますが、scaleはViewの大きさ自体は変わらず、中身の拡大率が変化します。

RowなどでComposable関数を並べたときに違いがわかります。
scaleのほうが他の要素に影響しづらいComposable関数になるのではないかなと思いました。

### sizeの場合

![record-211219154909.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/193770/d66f391e-8f2f-dfb3-f499-91fd8c864640.gif)

### scaleの場合

![record-211219154544.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/193770/8bccfe88-492b-a005-3934-fd421cb423cd.gif)


## 最終的なFavoriteButton

scaleを採用した最終的なFavoriteButtonはこうなりました。

```kotlin
@Composable
fun FavoriteButton(
    modifier: Modifier,
    buttonState: FavoriteButtonState,
    onToggle: () -> Unit,
) {
    val transition = updateTransition(buttonState, label = null)
    val scale by transition.animateFloat(label = "", transitionSpec = {
        keyframes {
            0.7f at 0 with FastOutSlowInEasing
            1.0f at 100 with FastOutSlowInEasing
            0.7f at 300 with FastOutSlowInEasing
        }
    }) { _state ->
        // if文使う必要も特にないので直接scale値指定しても大丈夫だと思います。
        if (_state.isFavorite) 0.7f else 0.7f
    }
    
    IconButton(
        onClick = { onToggle() },
        modifier = modifier
            .scale(scale)
    ) {
        Image(
            imageVector = ImageVector.Companion.vectorResource(
                if (buttonState.isFavorite) {
                    R.drawable.ic_star_off
                } else {
                    R.drawable.ic_star_on
                }
            ),
            contentDescription = "favorite",
        )
    }
}
```


### 余談）リファクタするなら？

scaleやsize、colorなど他の値も一緒にアニメーションするようなComposable関数を作る場合はTransitionDataのようなクラスを1つ作ってアニメーションの定義をそっちに移しても良さそうですね。

```kotlin
@Composable
fun FavoriteButton(
    modifier: Modifier,
    buttonState: FavoriteButtonState,
    onToggle: () -> Unit,
) {
    // こっちのほうがスッキリしてて個人的に好き
    val transitionData = updateTransitionData(state = buttonState)

    IconButton(
        onClick = { onToggle() },
        modifier = modifier
            .scale(transitionData.scale)
            .background(transitionData.color)
    ) {
        Image(
            imageVector = ImageVector.Companion.vectorResource(
                if (buttonState.isFavorite) {
                    R.drawable.ic_star_off
                } else {
                    R.drawable.ic_star_on
                }
            ),
            contentDescription = "favorite",
        )
    }
}

data class TransitionData(
    val color: Color,
    val size: Dp,
    val scale: Float,
)

@Composable
fun updateTransitionData(state: FavoriteButtonState): TransitionData {
    val transition = updateTransition(state, label = null)

    val color by transition.animateColor(label = "") { _state ->
        if (_state.isFavorite) Color.Red else Color.Blue
    }

    val size by transition.animateDp(label = "", transitionSpec = {
        keyframes {
            64.dp at 0 with FastOutSlowInEasing
            96.dp at 100 with FastOutSlowInEasing
            64.dp at 300 with FastOutSlowInEasing
        }
    }) { _state ->
        // if文使う必要も特にないので直接dp指定しても大丈夫だと思います。
        if (_state.isFavorite) 64.dp else 64.dp
    }

    val scale by transition.animateFloat(label = "", transitionSpec = {
        keyframes {
            0.7f at 0 with FastOutSlowInEasing
            1.0f at 100 with FastOutSlowInEasing
            0.7f at 300 with FastOutSlowInEasing
        }
    }) { _state ->
        // if文使う必要も特にないので直接scale値指定しても大丈夫だと思います。
        if (_state.isFavorite) 0.7f else 0.7f
    }

    return TransitionData(color, size, scale)
}
```

## まとめ

- updateTransitionとkeyFrameの組み合わせでタップアニメーションを作った
- いろいろなアニメーション方法があるので最適なものを選択していくと良さそう
- もっといい方法があるかもしれないので今後も模索していく
- jetpack compose記事もっと増えるといいな


良いjetpack composeライフを 👋