独自のremember関数を作ってUIの状態を整理する


Jetpack ComposeのrememberとMutableStateの一歩進んだ使い方として、独自のrememberXXXという関数を作ってUIの状態を管理する方法を紹介します。

前回の「もう雰囲気で使わない。rememberを理解するためのポイント」という記事では、remember(Mutable)State関連で理解があやふやになりやすい部分を整理しつつ、コンポーザブル関数内で変数を保持する方法を確認しました。

今回はもう一歩踏み込んで、独自のrememberXXXという関数を作ってUIの状態を管理する方法について調べます。まずは公式APIのrememberScrollStateを例に状態オブジェクトを返すremember関数の実装を読み解き、その後に基本的なrememberXXX関数の作り方を考えていきます。

状態オブジェクトを返すremember関数

コンポーザブル関数内の変数は、rememberMutableStateを組み合わせて使うことによって、状態変数として使うことができます。一つの変数で表せるような簡単な状態管理ならコンポーザブル関数内に直接remember { mutableStateOf() }と書けばよいです。ところが複雑な状態を表現しようとすると、UIコンポーネントの階層を実装したコードの中に状態管理のためのコードが混在し、見通しが悪くなってしまいます。

こうした問題を避けるためには、関連する状態変数を一つのクラスにカプセル化して保持するとよいです。Jetpack Composeでは、このような状態管理のためクラスがいろいろ用意されており、そのオブジェクトを取得する関数はrememberで始まる名前で定義されています。

代表的な例がrememberScrollStateです。この関数は、スクロール状態を管理するために必要な情報をまとめたScrollStateクラスのオブジェクトを返します。

rememberScrollState

では早速rememberScrollStateの実装を見てみます。

Scroll.kt
@Composable
fun rememberScrollState(initial: Int = 0): ScrollState {
return rememberSaveable(saver = ScrollState.Saver) {
ScrollState(initial = initial)
}
}

rememberScrollStateScrollStateオブジェクトを返すComposable関数です。rememberSaveableで保持しているので、初回コンポジションではScrollStateオブジェクトを作成し、2回目以降は初回に作成したオブジェクトを返します。rememberではなくrememberSaveableを使用しているのは、画面の向きの変更時にスクロール状態を保持するためだと思われます。

もう一つ重要なポイントは、戻り値が単純なScrollStateになっていて、MutableState<ScrollState>ではないということです。remember { mutableStateOf( ScrollState() ) }の形にはなっていません。そのため、ここを見るだけでは、ScrollStateが変化してもUIの再コンポジションはトリガされないように見えます。その謎を解くために、次はScrollStateの中身を見ていきます。

ScrollState

ScrollStateクラスの実装の冒頭部分はこのようになっています。

Scroll.kt
@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関数という関数があります。

Scroll.kt
class ScrollState(initial: Int) : ScrollableState {
...
suspend fun scrollTo(value: Int): Float = this.scrollBy((value - this.value).toFloat())
...
}

これを呼び出すと、まわりまわって以下のコードのラムダ式の部分が実行されます。ここでvalueを変更しています。

Scroll.kt
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を呼び出しています。

rememberMyStateMyStateオブジェクトを作成し、rememberで保持して返します。MyStatecolorプロパティを持っており、これがMutableStateになっています。changeColorメソッド内部でcolorを変更しているので、ボタンをクリックすると背景色が変わります。

変数としてはcolor一つだけのシンプルな実装ですが、when式による色変更のロジックをコンポーザブル階層とは切り離して実装できるので、コンポーザブル関数内に直接実装するよりも見通しが良くなっていると思います。

ViewModelとの使い分け

remember関数を自作することによって、UIの状態をコンポーザブル階層とは切り離して実装できることが分かりました。もしかするとここまで読んでくださった方の中には、それってViewModelの役割では?と思われる方もいるかもしれません。

ViewModelの役割や実装方法は流派がいろいろあったり、そもそも不要論があったりします。ですが私は、Repositoryとの間を仲立ちするのがViewModel、UIの状態やロジックを記述するのが今回紹介したrememberを使った方法、というように使い分けるのが良いと思っています。

参考:scrollToの中身

scrollTo関数呼び出しからvalueプロパティが変更されるラムダが呼ばれるまでの流れを以下で説明してみます。なかなか複雑なのでうまく説明できませんでしたので、参考程度に読んでください。。。

改めて、ScrollStateクラスのscrollTo関数です。ScrollableState.scrollByを呼び出しています。

Scroll.kt
class ScrollState(initial: Int) : ScrollableState {
...
suspend fun scrollTo(value: Int): Float = this.scrollBy((value - this.value).toFloat())
...
}

`ScrollableState`.scrollByでは、scroll関数を呼び出しています。scrollからはさらにScrollScope.scrollByを呼び出しています。

ScrollExtensions.kt
suspend fun ScrollableState.scrollBy(value: Float): Float {
var consumed = 0f
scroll {
consumed = scrollBy(value)
}
return consumed
}

ScrollableStateはインターフェースなので、scrollの実装はScrollState側にあります。

Scroll.kt
class ScrollState(initial: Int) : ScrollableState {
...
override suspend fun scroll(
scrollPriority: MutatePriority,
block: suspend ScrollScope.() -> Unit
): Unit = scrollableState.scroll(scrollPriority, block)
...
}

ScrollStatescrollでは、scrollableState.scrollを呼び出しています。scrollableStateScrollStateのプロパティで、以下のように定義されています。

Scroll.kt
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が実行されます。

ScrollableState.kt
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
}
}
}
...
}

これで、ScrollStatescrollToを呼び出したときにvalueが変更されるまでがつながりました。複雑。。。