【Kotlin】 Jetpack Composeを使ってりんご何個分？アプリを作ろう

この記事は[レコチョクAdvent Calendar 2024](https://qiita.com/advent-calendar/2024/recochoku) の21日目の記事となります。

## はじめに
こんにちは、株式会社レコチョクAndroidアプリ開発グループの我那覇です。
私は今年4月に新卒で入社し、10月よりAndroidグループに配属されました。

プログラミングは完全未経験の状態で入社し、Androidの開発は現在で3か月目となります。
今回はこれまでの学習を振り返りながら、簡単なアプリを作成したので紹介したいと思います！

## 自己紹介

- 四年制大学の音楽学科卒
    - 楽器、音楽療法、コンピュータ音楽(創作)を学ぶ
- プログラミング経験：入社前まではほぼ未経験
    - 入社後、レコチョクで5か月間のエンジニア研修を通して学ぶ
- 現在：Androidグループ配属され、OJT期間中
    - Googleが作成した教材「[Codelab](https://developer.android.com/courses?hl=ja)」を用いてAndroid開発を学習中

エンジニアとは縁のない人生を歩んできましたが、パソコンで音を入力・編集したり、自分の手で何かを作り出すことが好きでした。
また、大好きなサンリオのWebページやアプリを見て、この可愛いサイトやアプリはどうやってできているのだろう？と興味を持ったことが、エンジニアを目指したきっかけでした。

そこで今回は、サンリオの可愛いサイトを参考にアプリを作成しました！
至らない点も多くあるかと思いますが、ご指摘やアドバイスなどあれば遠慮なくいただけると嬉しいです。

## 目指すもの

今回参考にしたのは、[ハローキティはりんご3個分〜ドキドキ♡身体測定〜](https://www.sanrio.co.jp/special/hellokitty/apple/?utm_source=port&utm_medium=related&utm_campaign=apple_kt_240718_310)というサイトです。
KotlinとJetpack Composeを使用して身長をりんごに変換する機能を実装し、参考サイトのようにアニメーションを豊富に取り入れた見た目を目指しています。

**完成品**

![20241215_225124.GIF](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3934439/dd29065f-b5cf-e474-f0eb-ecba23fcff35.gif)


**やること**

- 入力画面
  - 入力された身長をりんごの高さに変換する
  - 測定ボタンを押すと結果表示画面に遷移する

- 結果表示画面
  - 変換した個数分のりんごの画像を表示する
  - りんごを上に積み上げる
  - りんご全体を左右に揺らす
  - りんごの数をカウントアップして表示する

**開発環境**

- macOS 14.4.1 Sonoma
- Android Studio Koala | 2024.1.1 Patch 1
- Kotlin 1.9.0
- Compose 1.6.6

## ロジックの作成
はじめに、ユーザーから入力された身長をりんごの高さに変換するロジックを作成します。
ここでは、UIと分離して管理するために`ViewModel`を用います。

```
// ViewModelクラスを拡張したConversionViewModelクラスを定義
class ConversionViewModel : ViewModel() {

    // 内部的にりんごの数を保持、データの変更を監視してリアルタイムで値を更新する
    private val _appleCount = MutableStateFlow(0.0)
    // 外部から参照できる、読み取り専用のデータ
    val appleCount: StateFlow<Double> get() = _appleCount

    // 身長をりんごに変換
    fun setHeight(height: Double) {
        val appleHeight = 8 // 1りんごの高さ(cm)
        _appleCount.value = height / appleHeight
    }
}
```
> 参考：コードラボ
[ViewModel にデータを保存する](https://developer.android.com/codelabs/basic-android-kotlin-training-viewmodel?hl=ja#0)

ユーザーが入力した身長を1りんごの高さ（8cm）で割り、その結果を`MutableStateFlow`で管理します。
これにより、他のコンポーネントがリアルタイムで結果を受け取ることができ、入力に応じてUIにりんごの個数が反映されます。

これで、身長をりんごの高さに変換するロジックができました。
※ 参考サイトのりんごは約10cmでしたが、私が購入したりんご(サンふじ)が8cmだったため高さを8cmに設定しています :apple: 

## UIの作成
**画面遷移**
身長を入力する画面から結果を表示する画面への画面遷移を実装します。

- **定数を定義**：`enum`(列挙型)のクラスを作成し、アプリ内の各画面を定義します。
- **ナビゲーションを設定**：画面間を移動するための`NavController`を使ってナビゲーションを管理し、`NavHost`で画面(フラグメント)を設定します。

```
// アプリ各画面の定数を格納
enum class AppleScaleScreen {
    Start, 　// 身長入力画面
    Conversion 　// 計算結果画面
}
```

```
@Composable
fun AppleScaleApp() {
    val navController = rememberNavController() // 画面間のナビゲーションを管理

    Scaffold (
        topBar = {
            AppleScaleAppBar(
                canNavigateBack = navController.previousBackStackEntry != null,
                navigateUp = { navController.navigateUp() }
            )
        }
    ){ innerPadding ->
        val viewModel: ConversionViewModel = viewModel()

        // ナビゲーションホストを設定、開始画面を指定
        NavHost(
            navController = navController,
            startDestination = AppleScaleScreen.Start.name,  // 最初に表示する画面
            modifier = Modifier.padding(innerPadding)
        ){
            // 入力画面
            composable(route = AppleScaleScreen.Start.name) {
                HeightInputScreen(
                    viewModel = viewModel,
                    onCalculateClicked = { _ ->  // ボタンをクリックしたときに呼び出され、次の画面に遷移する
                        navController.navigate(AppleScaleScreen.Conversion.name)
                    }
                )
            }

            // 結果画面
            composable(route = AppleScaleScreen.Conversion.name) {
                ConversionResultScreen(
                    viewModel = viewModel
                )
            }
        }
    }
}
```
> 参考：コードラボ
[Jetpack Compose でのナビゲーション](https://developer.android.com/courses/pathways/android-basics-compose-unit-4-pathway-2?hl=ja)

これで画面遷移の設定ができました。
次に、身長を入力する画面と結果を表示する画面を作成します。

---

**入力画面**
- **テキストフィールド**：`value`属性に`heightInput`を設定し、ユーザーの入力があるたびに`onValueChange`を通じて内容を更新するようにします。
- **ボタン**：ボタンがクリックされ、`Double`型に変換できた(有効な数値が入力された)時に`viewModel.setHeight(height)`を呼び出し、身長をりんごの個数に換算します。

![スクリーンショット 2024-12-16 1.10.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3934439/76b81da7-db3b-402e-b05b-41e244bef261.png)


```
// 身長入力画面
@Composable
fun HeightInputScreen(
    viewModel: ConversionViewModel,
    onCalculateClicked: (Double) -> Unit
) {
    var heightInput by remember { mutableStateOf("") }

    Column (
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.End
    ){
        OutlinedTextField(
            value = heightInput, // 現在の入力値を保持
            onValueChange = { heightInput = it }, // 入力された値 it を heightInput に反映して更新
            label = { Text(text = "身長(cm)") },
            modifier = Modifier.fillMaxWidth()
        )

        Button(
            onClick = {
                val height = heightInput.toDoubleOrNull() // 身長をDouble型に変換
                if (height != null) {
                    viewModel.setHeight(height) // viewModelのsetHeightを呼び出して処理を実行
                    onCalculateClicked(height)
                }
            }
        ) {
            Text(text = "測定する")
        }
    }
}
```
> 参考：コードラボ
[Compose の状態の概要](https://developer.android.com/codelabs/basic-android-kotlin-compose-using-state?hl=ja&continue=https%3A%2F%2Fdeveloper.android.com%2Fcourses%2Fpathways%2Fandroid-basics-compose-unit-2-pathway-3%3Fhl%3Dja%23codelab-https%3A%2F%2Fdeveloper.android.com%2Fcodelabs%2Fbasic-android-kotlin-compose-using-state#0)

---

**結果画面**
次に、ユーザーが入力した身長の結果をりんご単位で表示する画面を作成します。

- **りんごの取得と表示**：最初に作成した`ConversionViewModel`からりんごの数を取得し、それに基づいて身長がりんご何個分かを表示します。
- **フォーマット**：`"%.1f".format(appleCount)`を使用し、`appleCount`の値を小数点以下1桁まで表示するようにします。

![20241216_012514.GIF](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3934439/efd0e5ab-6b44-1da8-125c-94fc9c62f97a.gif)

```
// 結果表示画面
@Composable
fun ConversionResultScreen(viewModel: ConversionViewModel) {
    // ViewModelからりんごの数を取得して表示
    val appleCount by viewModel.appleCount.collectAsState(0.0)

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(
            text = "あなたの身長は...",
            color = Color(0xFFBA2636),
            fontSize = 16.sp
        )
        Text(
            text = "りんご${"%.1f".format(appleCount)}個分です！", // 小数点以下1桁まで
            color = Color(0xFFBA2636),
            fontSize = 24.sp
        )
    }
}
```

---

**りんごの画像を表示**
りんごを縦に並べて表示するために、`Column`コンポーザブルを使用して配置します。

 :bulb: Point：少数部分のりんごも画像で忠実に再現するために、整数部分と少数部分をリストに分け、それぞれに対応する画像IDを設定して表示するようにしました！

- **りんごの個数を取得**：取得した`appleCount`に基づいて、りんごの画像IDを含むリストを生成します。
- **スクロール機能**：`verticalScroll(rememberScrollState())`を使用し、多くのりんごが画面に表示された場合でも、スクロールして全体を表示できるようにします。
- **画像の表示**：生成された`appleList`内の各画像IDを`AppleItem`コンポーザブル関数を通じて描画し、各りんごの画像を表示します。
また、少数部分のりんごが画面の上部に配置されるように、`asReversed()`を利用してリストを逆順にしています。

![20241216_013416.GIF](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3934439/2dcc3a28-bf62-a93b-d74e-a04a8b89d9d6.gif)

```
@Composable
fun ConversionResultScreen(viewModel: ConversionViewModel) {
    // ViewModelからりんごの数を取得して画像IDのリストを生成
    val appleCount by viewModel.appleCount.collectAsState(0.0)
    val appleList = createAppleList(appleCount)

    Column(
        modifier = Modifier
            .fillMaxSize()
            .verticalScroll(rememberScrollState())
            .padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(0.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        // リストに含まれる各りんご画像を表示
        appleList.asReversed().forEach { appleId -> // リストを逆順で表示
            AppleItem(appleId = appleId)
        }
    }
}
```
```
// りんごの画像を表示するコンポーザブル関数
@Composable
fun AppleItem(appleId: Int) {
    Image(
        painter = painterResource(id = appleId),
        contentDescription = "Apple Image",
        modifier = Modifier.size(50.dp)
    )
}

// りんごの数に基づいて画像リストを生成する関数
fun createAppleList(appleCount: Double): List<Int> {
    val appleList = List(appleCount.toInt()) { R.drawable.apple_1 } // 整数のりんごの数だけapple_1のリストを作成
    val remains = appleCount - appleCount.toInt() // 少数部分のりんごを計算

    // 少数部分の条件に応じてりんごの画像IDを設定
    val fractionalAppleId = when {
        remains >= 0.6 -> R.drawable.apple_3_4
        remains >= 0.5 -> R.drawable.apple_1_2
        remains > 0 -> R.drawable.apple_1_4
        else -> null
    }

    // 少数部分のりんごが存在する場合、整数部分のリストに追加して返す
    return if (fractionalAppleId != null) appleList + fractionalAppleId else appleList
}
```
> 参考：コードラボ
[Kotlin でコレクションを使用する](https://developer.android.com/codelabs/basic-android-kotlin-compose-collections?hl=ja&continue=https%3A%2F%2Fdeveloper.android.com%2Fcourses%2Fpathways%2Fandroid-basics-compose-unit-3-pathway-1%3Fhl%3Dja%23codelab-https%3A%2F%2Fdeveloper.android.com%2Fcodelabs%2Fbasic-android-kotlin-compose-collections#0)
[基本的なレイアウトを作成する](https://developer.android.com/courses/pathways/android-basics-compose-unit-1-pathway-3?hl=ja)

![スクリーンショット 2024-12-16 0.57.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3934439/ae3aae3f-f0d8-5611-ab92-84417eb7477a.png)

ここでは`when`式を使用し、小数部分のりんごに対応した画像IDを選択するための条件分岐を行っています。

これで、身長をりんごの高さで表現することができました！

## アニメーションの追加
最後に、より[参考サイト](https://www.sanrio.co.jp/special/hellokitty/apple/?utm_source=port&utm_medium=related&utm_campaign=apple_kt_240718_310)に近づけるためにアニメーションを追加します。

**りんごを左右に揺らす**
- **回転の初期値を設定**：`remember { Animatable(-2f) }`として、回転角度の初期値を`-2f`(-2度の位置)に設定します。
- **アニメーションの設定**
  - `targetValue`でアニメーションの目標値を2fに設定し、-2度から2度の間で左右に揺れるようにします。
  - `animationSpec = infiniteRepeatable`を使用して、アニメーションが繰り返されるように設定します。
  - `FastOutSlowInEasing`を使用して、アニメーションが素早く始まり徐々に減速するように設定します。これにより、りんごが自然に揺れているように見せることができます。
  - `RepeatMode.Reverse`を使用して、アニメーションが終了したときに反転し、逆方向に進むようにします。これで左右に揺れるアニメーションを連続させることができます。
- **基点の位置を設定**：`graphicsLayer`プロパティ
  - 回転の基点を、水平方向に`0.5f`(コンポーネントの幅に対して50%の位置　中央)、垂直方向 に`1f`(コンポーネントの高さに対して100%の位置　一番下)に設定します。

こうすることで、積み上がったりんごが一番下のりんごを基点に、左右に揺れるようになりました！

![20241216_021702.GIF](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3934439/06f54f53-42e2-8e45-1f1b-7b9d39c19e44.gif)

```
@Composable
fun ConversionResultScreen(viewModel: ConversionViewModel) {
    // りんごの揺れの起点を設定
    val swingAnimation = remember { Animatable(-2f) }

    // りんご全体を左右に揺らすアニメーション
    LaunchedEffect(Unit) {
        swingAnimation.animateTo(
            targetValue = 2f, // 回転の角度
            animationSpec = infiniteRepeatable(
                animation = tween(
                    durationMillis = 3500, // 秒数(ミリ秒)
                    easing = FastOutSlowInEasing // 徐々に減速
                ),
                repeatMode = RepeatMode.Reverse // 逆方向から再開
            )
        )
    }
```
```
    Column(
        modifier = Modifier
            .fillMaxSize()
            .graphicsLayer {
                rotationZ = swingAnimation.value
                transformOrigin = TransformOrigin(0.5f, 1f) // 起点の位置
            },
        // 他の設定...
    ) {
        // りんごの画像表示...
    }
```
> 参考：[アニメーションをカスタマイズする](https://developer.android.com/develop/ui/compose/animation/customize?hl=ja)

**文字のアニメーション**
最後に、りんごの数を表示する数値部分にカウントアップ表示のアニメーションを設定します。
これは揺れのアニメーションとほぼ同様の方法で実装しているため、詳細な説明は省略します。

- `remember { Animatable(0f) }`で初期値を保持します。
- `animationSpec`の`tween`関数を使用し、2000ミリ秒（2秒）かけて加速させます。
同時に、`FastOutSlowInEasing`を設定して始まりと終わりが緩やかになるように調整しています。

![20241216_022844.GIF](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3934439/8ce21bfb-f483-e764-fa6a-9919e7131540.gif)

```
@Composable
fun ConversionResultScreen(viewModel: ConversionViewModel) {
    // ViewModelからりんごの数を取得して表示
    val appleCount by viewModel.appleCount.collectAsState(0.0)

    // りんごの数をカウントアップさせる
    val displayedAppleCount = remember { Animatable(0f) }

    // りんごの数をカウントアップ
    LaunchedEffect(appleCount) {
        displayedAppleCount.animateTo(
            targetValue = appleCount.toFloat(),
            animationSpec = tween(
                durationMillis = 2000,
                easing = FastOutSlowInEasing
            )
        )
    }

    // テキスト表示
    Text(
        text = "りんご${"%.1f".format(displayedAppleCount.value)}個分です！",
        color = Color(0xFFBA2636),
        fontSize = 24.sp
    )
}
```

## 最後に
長文となりましたが、最後までお読みいただきありがとうございます！
学んだ内容を振り返りつつ、目指していたものを形にすることができました。
想定通りにいかないことも多く苦戦しましたが、一からアプリを作成し記事にまとめる過程で、学んだことをアウトプットし、より一層理解を深めることができたと感じています。

これからも楽しみながら、一歩一歩頑張っていきたいと思います。

明日の [レコチョク Advent Calendar 2024](https://qiita.com/advent-calendar/2024/recochoku) は22日目「デザインシステムでデザインが"ととのい"はじめた」です。お楽しみに！