[Jetpack Compose] アニメーション付いいねボタンを作る

# 概要
いいねボタンやブックマークボタン等、さまざまなアプリでアイコンボタンが利用されていると思います。その中でも有名なアプリの中で利用されているアイコンボタンには押下時のアニメーションが実装されており、よりリッチな表現を実現しています。

今回はJetpack Composeを用いてそのようなアニメーション付きのアイコンボタンを作成します。例としてInstagramのいいねボタンを再現してみます。

## Instagramのいいねボタン
|　　　　　　　　  　　　　　　　　   　　　　　　　　　　　　　　　　　　　　　　　　　|
|:-:|
|<img width="390" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3553346/c27c6d69-58a6-e170-86bf-11e7c977a408.gif">|


## 今回作ったいいねボタン
まあまあ本物に寄せられたかな、、？

<img width="1000" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3553346/52cd8a73-3a55-b386-82de-2deefeb54f96.gif">

# 前提条件
未いいね状態アイコンといいね済み状態アイコンにはそれぞれ、[Vector Assetのマテリアルアイコン](https://developer.android.com/studio/write/vector-asset-studio?hl=ja#importing)を利用しました。追加手順等は[こちら](https://developer.android.com/studio/write/vector-asset-studio?hl=ja#running)

**未いいね状態アイコン**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3553346/f211d12b-3f60-48b6-fd55-150e46c101d1.png)

**いいね済み状態アイコン**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3553346/8a469af7-7462-5ec1-519f-670e161fd370.png)

# いいねボタンComposableの全体コード
```Kotlin
@Composable
fun FavoriteIcon(
    modifier: Modifier = Modifier,
) {
    // いいね状態
    var isFavorite by rememberSaveable {
        mutableStateOf(false)
    }

    // 未いいね状態アイコンのアニメーション定義
    val notFavoriteAnimationScale = animateFloatAsState(
        // いいね状態によって表示/非表示を切り替える
        targetValue = if (!isFavorite) 1.0F else 0F,
        animationSpec = if (!isFavorite) {
            keyframes {
                durationMillis = 400
                0.0f at 0 // 0msの時に0倍の大きさで表示
                1.2f at 250 // 0ms〜250msにかけて0倍 → 1.2倍へニュルっと拡大
                1.0f at 400 // 250ms〜400msにかけて1.2倍 → 1.0倍へニュルっと縮小
            }
        } else {
            snap()
        },
        label = "",
    )

    // いいね済み状態アイコンのアニメーション定義
    val favoriteAnimationScale = animateFloatAsState(
        // いいね状態によって表示/非表示を切り替える
        targetValue = if (isFavorite) 1.0F else 0F,
        animationSpec = if (isFavorite) {
            keyframes {
                durationMillis = 400
                0.0f at 0 // 0msの時に0倍の大きさで表示
                1.2f at 250 // 0ms〜250msにかけて0倍 → 1.2倍へニュルっと拡大
                1.0f at 400 // 250ms〜400msにかけて1.2倍 → 1.0倍へニュルっと縮小
            }
        } else {
            snap()
        },
        label = "",
    )

    val iconSize = 300.dp
    Box(
        modifier = modifier
            .size(iconSize)
            .clickable(
                interactionSource = remember { MutableInteractionSource() },
                indication = null,
            ) {
                // 押下時のいいね状態の変更処理
                isFavorite = !isFavorite
            },
    ) {
        // 未いいね状態アイコン、いいね済み状態アイコンを重ねて配置しておく

        // 未いいね状態アイコン
        Icon(
            modifier = Modifier
                .size(iconSize)
                // ここでアニメーションを適用している
                .scale(notFavoriteAnimationScale.value),
            painter = painterResource(id = R.drawable.baseline_favorite_border_24),
            contentDescription = null,
            tint = Color(0xFF000000)
        )
        // いいね済み状態アイコン
        Icon(
            modifier = Modifier
                .size(iconSize)
                // ここでアニメーションを適用している
                .scale(favoriteAnimationScale.value),
            painter = painterResource(id = R.drawable.baseline_favorite_24),
            contentDescription = null,
            tint = Color(0xFFFC3A3A),
        )
    }
}
```

# コードのポイント

### アニメーションの定義
```Kotlin
    // 未いいね状態アイコンのアニメーション定義
    val notFavoriteAnimationScale = animateFloatAsState(
        // いいね状態によって表示/非表示を切り替える
        targetValue = if (!isFavorite) 1.0F else 0F,
        animationSpec = if (!isFavorite) {
            keyframes {
                durationMillis = 400
                0.0f at 0 // 0msの時に0倍の大きさで表示
                1.2f at 250 // 0ms〜250msにかけて0倍 → 1.2倍へニュルっと拡大
                1.0f at 400 // 250ms〜400msにかけて1.2倍 → 1.0倍へニュルっと縮小
            }
        } else {
            snap()
        },
        label = "",
    )

    // いいね済み状態アイコンのアニメーション定義
    val favoriteAnimationScale = animateFloatAsState(
        // いいね状態によって表示/非表示を切り替える
        targetValue = if (isFavorite) 1.0F else 0F,
        animationSpec = if (isFavorite) {
            keyframes {
                durationMillis = 400
                0.0f at 0 // 0msの時に0倍の大きさで表示
                1.2f at 250 // 0ms〜250msにかけて0倍 → 1.2倍へニュルっと拡大
                1.0f at 400 // 250ms〜400msにかけて1.2倍 → 1.0倍へニュルっと縮小
            }
        } else {
            snap()
        },
        label = "",
    )
```
```notFavoriteAnimationScale```では未いいね状態アイコンのアニメーションを、```favoriteAnimationScale```ではいいね済み状態アイコンのアニメーションをそれぞれ定義しています。これらのアニメーション定義を、アニメーション追加対象のComposableにてModifier.scale()を用いて付与することでアニメーションの実現を行います。

アニメーションの実現には[animateFloatAsState](https://developer.android.com/jetpack/compose/animation?hl=ja#animationspec)を利用しています。その中のパラメータである```targetValue```、```animationSpec```にて具体的な振る舞いを定義することができます。

- **targetValue**
アニメーション終了時の大きさを指定しています。

- **animationSpec**
時間の経過とともに値が変化するアニメーションを指定できます。
ここでは[Keyframes](https://developer.android.com/jetpack/compose/animation?hl=ja#keyframes)を利用して、経過時間ごとのアイコンの状態を指定しています。```durationMillis```でアニメーションの全体時間を指定し、その下に何ミリ秒時点で何倍のサイズのアイコンを表示するかを列挙しています（```1.2f at 250```等）。

また[snap()](https://developer.android.com/jetpack/compose/animation?hl=ja#snap)は各アイコンを非表示にするために利用しています。[snap()](https://developer.android.com/jetpack/compose/animation?hl=ja#snap)はアニメーション終了時の状態へすぐに切り替えるためのメソッドです。


### 同じBox内に変化前後のアイコンを重ねて配置する
```Kotlin
    Box(
        modifier = modifier
            .size(iconSize)
            .clickable(
                interactionSource = remember { MutableInteractionSource() },
                indication = null,
            ) {
                // 押下時のいいね状態の変更処理
                isFavorite = !isFavorite
            },
    ) {
        // 未いいね状態アイコン、いいね済み状態アイコンを重ねて配置しておく

        // 未いいね状態アイコン
        Icon(
            modifier = Modifier
                .size(iconSize)
                // ここでアニメーションを適用している
                .scale(notFavoriteAnimationScale.value),
            painter = painterResource(id = drawable.baseline_favorite_border_24),
            contentDescription = null,
            tint = Color(0xFF000000)
        )
        // いいね済み状態アイコン
        Icon(
            modifier = Modifier
                .size(iconSize)
                // ここでアニメーションを適用している
                .scale(favoriteAnimationScale.value),
            painter = painterResource(id = drawable.baseline_favorite_24),
            contentDescription = null,
            tint = Color(0xFFFC3A3A),
        )
    }
```
Boxの中に未いいね状態アイコンといいね済み状態アイコンを重ねて定義しておきます。
アニメーション定義の部分で、```isFavorite```(いいね状態)を元にアイコンの表示/非表示をハンドリングしているため、必ずどちらかのアイコンが表示、どちらかが非表示の状態になっています。

# 参考文献
https://developer.android.com/jetpack/compose/animation?hl=ja

https://developer.android.com/studio/write/vector-asset-studio?hl=ja