Jetpack ComposeのrememberとMutableStateの一歩進んだ使い方として、独自のrememberXXXという関数を作ってUIの状態を管理する方法を紹介します。
前回の「もう雰囲気で使わない。rememberを理解するためのポイント」という記事では、remember
や(Mutable)State
関連で理解があやふやになりやすい部分を整理しつつ、コンポーザブル関数内で変数を保持する方法を確認しました。
今回はもう一歩踏み込んで、独自のrememberXXXという関数を作ってUIの状態を管理する方法について調べます。まずは公式APIのrememberScrollState
を例に状態オブジェクトを返すremember関数の実装を読み解き、その後に基本的なrememberXXX関数の作り方を考えていきます。
目次
状態オブジェクトを返すremember関数
コンポーザブル関数内の変数は、remember
とMutableState
を組み合わせて使うことによって、状態変数として使うことができます。一つの変数で表せるような簡単な状態管理ならコンポーザブル関数内に直接remember { mutableStateOf() }
と書けばよいです。ところが複雑な状態を表現しようとすると、UIコンポーネントの階層を実装したコードの中に状態管理のためのコードが混在し、見通しが悪くなってしまいます。
こうした問題を避けるためには、関連する状態変数を一つのクラスにカプセル化して保持するとよいです。Jetpack Composeでは、このような状態管理のためクラスがいろいろ用意されており、そのオブジェクトを取得する関数はrememberで始まる名前で定義されています。
代表的な例がrememberScrollState
です。この関数は、スクロール状態を管理するために必要な情報をまとめたScrollState
クラスのオブジェクトを返します。
rememberScrollState
では早速rememberScrollState
の実装を見てみます。
@Composable
fun rememberScrollState(initial: Int = 0): ScrollState {
return rememberSaveable(saver = ScrollState.Saver) {
ScrollState(initial = initial)
}
}
rememberScrollState
はScrollState
オブジェクトを返すComposable関数です。rememberSaveable
で保持しているので、初回コンポジションではScrollStateオブジェクトを作成し、2回目以降は初回に作成したオブジェクトを返します。remember
ではなくrememberSaveable
を使用しているのは、画面の向きの変更時にスクロール状態を保持するためだと思われます。
もう一つ重要なポイントは、戻り値が単純なScrollState
になっていて、MutableState<ScrollState>
ではないということです。remember { mutableStateOf( ScrollState() ) }
の形にはなっていません。そのため、ここを見るだけでは、ScrollState
が変化してもUIの再コンポジションはトリガされないように見えます。その謎を解くために、次はScrollState
の中身を見ていきます。
ScrollState
ScrollState
クラスの実装の冒頭部分はこのようになっています。
@Stable
class ScrollState(initial: Int) : ScrollableState {
var value: Int by mutableStateOf(initial, structuralEqualityPolicy())
private set
...
}
value
はスクロールポジションを表すプロパティですが、これがMutableState
になっていることが分かります。value
の他にもいくつかのパラメータがMutableState
で定義されていますが、以降ではvalue
について詳しく見ていきます。
value
プロパティはprivate set
で定義されているため、rememberScrollState
の呼び出し側から見るとvalue
は読み取り専用です。ScrollState
クラス内部でvalue
が変更されると、これを参照しているコンポーザブルが再コンポジションされるという形です。
ではvalue
はどのように変更されるのでしょうか。
例えば、ScrollState
クラスのscrollTo
関数という関数があります。
class ScrollState(initial: Int) : ScrollableState {
...
suspend fun scrollTo(value: Int): Float = this.scrollBy((value - this.value).toFloat())
...
}
これを呼び出すと、まわりまわって以下のコードのラムダ式の部分が実行されます。ここでvalue
を変更しています。
class ScrollState(initial: Int) : ScrollableState {
...
private val scrollableState = ScrollableState {
val absolute = (value + it + accumulator)
val newValue = absolute.coerceIn(0f, maxValue.toFloat())
val changed = absolute != newValue
val consumed = newValue - value
val consumedInt = consumed.roundToInt()
value += consumedInt
accumulator = consumed - consumedInt
// Avoid floating-point rounding error
if (changed) consumed else it
}
...
}
scrollTo
から上記のラムダ式がどのように呼ばれるかは、ちょっと複雑です。この記事の末尾に説明を書きましたので、興味のある方は読んでください。ただあまりうまく説明できている自信がないので、実際のコードを見た方が早いかもしれません。
独自のremember関数の作り方
さて、ここまでで調べたrememberScrollState
の構造を一般化すると、独自のクラスをJetpack Composeで扱う時の定型パターンが見えてきます。MyStateというクラスを作る手順は以下のようになります。
- rememberMyState関数内でMyStateクラスのオブジェクトを
remember
で保持し、これを戻り値とする。 - MyStateクラスには状態の管理に必要なプロパティをMutableStateで定義する。
- MyStateクラスの状態を変更する関数を定義し、関数内でMutableStateを変更する。
サンプル
ボタンをクリックするたびに背景色を変化させるサンプルを作ってみました。
@Composable
fun MyRememberFunctionSample() {
val myState = rememberMyState()
MyStateScreen(myState = myState)
}
@Composable
fun MyStateScreen(myState: MyState) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.background(myState.color)
.fillMaxSize()
) {
Button(
onClick = myState::changeColor,
) {
Text("Change Background Color")
}
}
}
@Composable
fun rememberMyState(): MyState {
return remember {
MyState()
}
}
@Stable
class MyState {
var color by mutableStateOf(Color.Red)
private set
fun changeColor() {
color = when (color) {
Color.Red -> Color.Green
Color.Green -> Color.Blue
else -> Color.Red
}
}
}
コンポーザブル関数内で、自作のremember関数 rememberMyState
を呼び出し、MyState
オブジェクトを取得しています。背景色にMyState.color
を設定し、ボタンクリック時にMyState.changeColor
を呼び出しています。
rememberMyState
はMyState
オブジェクトを作成し、remember
で保持して返します。MyState
はcolor
プロパティを持っており、これがMutableState
になっています。changeColor
メソッド内部でcolor
を変更しているので、ボタンをクリックすると背景色が変わります。
変数としてはcolor
一つだけのシンプルな実装ですが、when
式による色変更のロジックをコンポーザブル階層とは切り離して実装できるので、コンポーザブル関数内に直接実装するよりも見通しが良くなっていると思います。
ViewModelとの使い分け
remember関数を自作することによって、UIの状態をコンポーザブル階層とは切り離して実装できることが分かりました。もしかするとここまで読んでくださった方の中には、それってViewModelの役割では?と思われる方もいるかもしれません。
ViewModelの役割や実装方法は流派がいろいろあったり、そもそも不要論があったりします。ですが私は、Repositoryとの間を仲立ちするのがViewModel、UIの状態やロジックを記述するのが今回紹介したrememberを使った方法、というように使い分けるのが良いと思っています。
参考:scrollToの中身
scrollTo
関数呼び出しからvalue
プロパティが変更されるラムダが呼ばれるまでの流れを以下で説明してみます。なかなか複雑なのでうまく説明できませんでしたので、参考程度に読んでください。。。
改めて、ScrollState
クラスのscrollTo
関数です。ScrollableState.scrollBy
を呼び出しています。
class ScrollState(initial: Int) : ScrollableState {
...
suspend fun scrollTo(value: Int): Float = this.scrollBy((value - this.value).toFloat())
...
}
.ScrollableState
scrollBy
では、scroll
関数を呼び出しています。scroll
からはさらにScrollScope.scrollBy
を呼び出しています。
suspend fun ScrollableState.scrollBy(value: Float): Float {
var consumed = 0f
scroll {
consumed = scrollBy(value)
}
return consumed
}
ScrollableState
はインターフェースなので、scroll
の実装はScrollState
側にあります。
class ScrollState(initial: Int) : ScrollableState {
...
override suspend fun scroll(
scrollPriority: MutatePriority,
block: suspend ScrollScope.() -> Unit
): Unit = scrollableState.scroll(scrollPriority, block)
...
}
ScrollState
のscroll
では、scrollableState.scroll
を呼び出しています。scrollableState
はScrollState
のプロパティで、以下のように定義されています。
class ScrollState(initial: Int) : ScrollableState {
...
private val scrollableState = ScrollableState {
val absolute = (value + it + accumulator)
val newValue = absolute.coerceIn(0f, maxValue.toFloat())
val changed = absolute != newValue
val consumed = newValue - value
val consumedInt = consumed.roundToInt()
value += consumedInt
accumulator = consumed - consumedInt
// Avoid floating-point rounding error
if (changed) consumed else it
}
...
}
ScrollableState関数の引数のラムダ式は、ScrollableStateインターフェースの実装であるDefaultScrollableState
オブジェクトを作成する際に渡され、scrollScopeオブジェクトのscrollBy
の実体となります。そして、scroll
関数が呼ばれたときにこのscrollScopeのscrollByが実行されます。
fun ScrollableState(consumeScrollDelta: (Float) -> Float): ScrollableState {
return DefaultScrollableState(consumeScrollDelta)
}
private class DefaultScrollableState(val onDelta: (Float) -> Float) : ScrollableState {
...
private val scrollScope: ScrollScope = object : ScrollScope {
override fun scrollBy(pixels: Float): Float = onDelta(pixels)
}
...
override suspend fun scroll(
scrollPriority: MutatePriority,
block: suspend ScrollScope.() -> Unit
): Unit = coroutineScope {
scrollMutex.mutateWith(scrollScope, scrollPriority) {
isScrollingState.value = true
try {
block()
} finally {
isScrollingState.value = false
}
}
}
...
}
これで、ScrollState
のscrollTo
を呼び出したときにvalue
が変更されるまでがつながりました。複雑。。。