Android | Compose で RatingBar を作成。State クラスと rememberSaveable で状態を管理。

2023/03/11に公開

やりたいこと

現時点では Android Compose に RatingBar が存在しないので、それっぽいビューを作ってみました。

画面が再生成されても評価の値を維持するために、RatingBar 用の State クラスを作って rememberSaveable で保存する実装を試してみました。今後も似たような実装が出てきそうなので備忘録的として残しておこうと思います。

※本来の RatingBar は SeekBar を継承しているので、スワイプすることで数値を変更できるのですが、今回はタップのみで変更できるようになっています。悪しからず。

実装

星を並べる★

ひとまず星が並んでいるビューを作成します。ここは単に塗りつぶされている星★と空っぽの星☆の画像を並べているだけなので、説明は割愛します。

なお R.drawable.ic_star_fillR.drawable.ic_star_empty の画像は Vector Asset Studio で作成したものを使用しています。

RatingBar.kt
// 今回は最大値を 5 として決め撃ち
const val MAX_RATING = 5

@Composable
fun RatingBar(
    // 評価の初期値
    @IntRange(from = 0, to = MAX_RATING.toLong())
    rating: Int,
    // クリックで評価を変更できるかどうか
    isClickable: Boolean = false,
    // 評価が変更されたときのコールバック
    onChangeRating: ((Int) -> Unit)? = null,
) {
    Row {
        repeat(MAX_RATING) { i ->
            RatingStar(isFilledStar = i < rating, i + 1) { newRating ->
                if (isClickable) {
                    onChangeRating?.invoke(newRating)
                }
            }
        }
    }
}

@Composable
private fun RatingStar(isFilledStar: Boolean, rating: Int, onClick: ((Int) -> Unit)) {
    if (isFilledStar) {
        Icon(
            painter = painterResource(id = R.drawable.ic_star_fill),
            contentDescription = null,
            tint = colorResource(id = R.color.start_color),
            modifier = Modifier.clickable { onClick(rating) },
        )
    } else {
        Icon(
            painter = painterResource(id = R.drawable.ic_star_empty),
            contentDescription = null,
            tint = colorResource(id = R.color.start_color),
            modifier = Modifier.clickable { onClick(rating) },
        )
    }
}

@Preview
@Composable
private fun PreviewRatingBar() {
    RatingBar(rating = 3, isClickable = true, onChangeRating = null)
}

これで星が並んでいるビューができあがりました。
しかしこのままでは星をタップしても評価が増減してくれません。

ビューの状態を管理する

星をタップして評価を増減させるために、評価を管理するクラスを作り、その評価の値に応じてビューが反映されるように実装します。

RatingBar.kt
const val MAX_RATING = 5

// ① 評価の値を保持するクラスを作る
@Parcelize
data class RatingBarState(
    @IntRange(from = 0, to = MAX_RATING.toLong())
    val rating: Int,  // 評価
    val hoge: String, // 評価以外に保存したいデータがあれば追加
    val fuga: String, // 評価以外に保存したいデータがあれば追加
) : Parcelable

@Composable
fun RatingBar(
    @IntRange(from = 0, to = MAX_RATING.toLong())
    val rating: Int,
    isClickable: Boolean = false,
    onChangeRating: ((Int) -> Unit)? = null,
) {
    // ② RatingBarState を rememberSaveable に渡す
    var state by rememberSaveable(rating) {
        // ③ RatingBarState を MutableState でラップする
        mutableStateOf(RatingBarState(rating))
    }

    Row {
        repeat(MAX_RATING) { i ->
            RatingStar(isFilledStar = i < state.rating, i + 1) { newRating ->
                if (isClickable) {
		    // ④ RatingBarState の値を更新する
                    state = state.copy(rating = newRating)
                    onChangeRating?.invoke(newRating)
                }
            }
        }
    }

① RatingBarState というクラスを作り、保持しておきたいデータを持たせます。後ほど出てくる rememberSaveable は内部的に Bundle を使用してデータを保存するため、RatingBarState クラスも Bundle に追加できるよう Parcelable を実装します。(参考

② その状態クラスを RatingStar() の冒頭で rememberSavealbe に渡すことで再コンポーズやアクティビティの再生成が発生しても状態を保持できるようにします。

③ このとき RatingBarState は MutableState でラップします。コンポーザブルは MutableState を監視することができるので、RatingBarState の状態を監視して UI を更新する処理が可能になります。

④ 星がタップされ、評価が変更されたタイミングで state に新しい状態を代入します。これにより state の更新がコンポーザブルに通知され、最終的に UI が更新されます。

これで再コンポーズやアクティビティの再生成に耐えられる RatingBar が出来上がりました。

Discussion