1. はじめに
この Codelab では、状態の概要、使用方法、Jetpack Compose で操作する方法について説明します。
まず、状態を正確に定義します。本質的には、アプリにおける状態とは、時間とともに変化する可能性がある値すべてを指します。これは非常に広範な定義であり、Room データベースにも、クラス内の変数一つにも当てはまります。
すべての Android アプリはユーザーに状態を表示します。Android アプリの状態の例を次にいくつか示します。
- ネットワーク接続を確立できないときに表示されるスナックバー
- ブログ投稿と関連コメント
- ユーザーがボタンをクリックしたときに再生されるボタンの波紋アニメーション
- ユーザーが画像の上に描画できるステッカー
この Codelab では、Jetpack Compose を使用する際の状態の考え方と使い方を学びます。そのために、TODO アプリを作成します。この Codelab の最後には、インタラクティブで編集可能な TODO リストを表示するステートフルな UI が完成します。
次のセクションでは「単方向データフロー」という、Compose を使用する際に状態を表示する方法と管理する方法を理解するうえでの中核となるデザイン パターンについて学習します。
学習内容
- 単方向データフローとは
- UI における状態とイベントの考え方
- Compose でアーキテクチャ コンポーネントの
ViewModel
とLiveData
を使用して状態を管理する方法 - Compose が状態を使用して画面を描画する仕組み
- 呼び出し元に状態を移動するタイミング
- Compose での内部状態の使用方法
State<T>
を使用して状態を Compose と統合する方法
必要なもの
- Android Studio Bumblebee
- Kotlin に関する知識
- この Codelab の前に Jetpack Compose の基本の Codelab を受講することを検討してください。
- Compose に関する基礎知識(
@Composable
アノテーションなど) - Compose レイアウト(Row や Column など)に関する基本的な知識
- 修飾子(Modifier.padding など)に関する基本的な知識
- アーキテクチャ コンポーネントの
ViewModel
とLiveData
に関する基礎知識
作成するアプリの概要
- Compose で単方向データフローを使用するインタラクティブな TODO アプリ
2. 設定方法
サンプルアプリを次のいずれかの方法でダウンロードします。
または次のコマンドを使用して、コマンドラインから GitHub リポジトリのクローンを作成します。
git clone https://github.com/googlecodelabs/android-compose-codelabs.git cd android-compose-codelabs/StateCodelab
どちらのモジュールも、Android Studio のツールバーで実行構成を変更することでいつでも実行できます。
Android Studio でプロジェクトを開く
- [Welcome to Android Studio] ウィンドウで、
[Open an Existing Project] を選択します。
[Download Location]/StateCodelab
フォルダを選択します(ヒント:build.gradle
が入っているStateCodelab
ディレクトリを選択してください)。- Android Studio にプロジェクトがインポートされたら、
start
モジュールとfinished
モジュールを実行できるかどうかテストします。
開始コードを確認する
開始コードには、次の 4 つのパッケージが含まれています。
examples
- 単方向データフローのコンセプトを確認するためのサンプル アクティビティ。このパッケージを編集する必要はありません。ui
– 新しい Compose プロジェクトの開始時に Android Studio が自動生成したテーマが含まれています。このパッケージを編集する必要はありません。util
– プロジェクトのヘルパーコードが入っています。このパッケージを編集する必要はありません。todo
– 作成する ToDo 画面のコードが入っているパッケージです。このパッケージに変更を加えます。
この Codelab では、todo
パッケージのファイルについて説明します。start
モジュールに、これから学習するファイルがあります。
todo
パッケージで提供されるファイル
Data.kt
–TodoItem
の表現に使用されるデータ構造です。TodoComponents.kt
– ToDo 画面の作成に使用する再利用可能なコンポーザブルです。このファイルを編集する必要はありません。
todo
パッケージ内の編集するファイル
TodoActivity.kt
– この Codelab の完了後に Compose を使用して ToDo 画面を描画するようになる Android Activity です。TodoViewModel.kt
– ToDo 画面を作成するために Compose と統合するViewModel
です。この Codelab を終えるまでの間に、Compose に接続し、拡張して機能を追加します。TodoScreen.kt
- この Codelab で作成する ToDo 画面の Compose 実装。
3. 単方向データフローを理解する
UI 更新ループ
TODO アプリに進む前に、Android ビューシステムを使用した単方向データフローのコンセプトについて説明します。
何が状態の更新を引き起こすのでしょうか?冒頭では、状態を時間とともに変化する値と説明しました。これは、Android アプリにおける状態を一部しか説明していません。
Android アプリでは、イベントに応答して状態が更新されます。イベントとはアプリ外で生成された入力であり、OnClickListener
を呼び出すボタンをユーザーがタップする操作、EditText
による afterTextChanged
の呼び出し、加速度計による新しい値の送信などがあります。
すべての Android アプリは、次のようなコア UI 更新ループを備えています。
- イベント – ユーザーまたはプログラムの要素によってイベントが生成されます。
- 状態の更新 – イベント ハンドラが UI で使用される状態を変更します。
- 状態の表示 – UI が更新され、新しい状態を表示します。
状態とイベントの相互作用を理解すれば、Compose の状態を扱えるようになります。
構造化されていない状態
Compose に取り掛かる前に、Android ビューシステムにおけるイベントと状態を見てみましょう。状態の「Hello World」として、ユーザーが名前を入力できる Hello World Activity
を作成します。
イベント コールバックで TextView の状態を直接設定する方法の場合、コードは ViewBinding
を使った次のようなものになります。
HelloCodelabActivity**.kt**
class HelloCodelabActivity : AppCompatActivity() {
private lateinit var binding: ActivityHelloCodelabBinding
var name = ""
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
binding.textInput.doAfterTextChanged {text ->
name = text.toString()
updateHello()
}
}
private fun updateHello() {
binding.helloText.text = "Hello, $name"
}
}
このコードは機能し、このように小規模な場合には問題ありません。しかし、UI が大きくなると管理は難しくなりがちです。
作成された Activity にさらにイベントや状態を追加すると、以下のような問題が発生する可能性があります。
- テスト – UI の状態と
Views
とが絡み合って、コードのテストが難しくなる可能性があります。 - 不完全な状態更新 - 画面のイベントが増えると、イベントに応答して状態の一部を更新することを忘れやすくなります。その結果、UI の不整合や誤りが生じる可能性があります。
- 不完全な UI 更新 - 状態が変化するたびに手動で UI を更新しているため、簡単に更新を忘れてしまいます。その結果、ランダムに更新される UI に古いデータが表示されることになります。
- コードの複雑さ - このパターンでコーディングを行っていると、ロジックの一部を抽出することが困難になります。その結果、読むのも理解するのも困難なコードになりがちです。
単方向データフローの使用
このような構造化されていない状態に関する問題を解決するために、ViewModel
と LiveData
を含む Android アーキテクチャ コンポーネントを導入しました。
ViewModel
を使用すると、UI から状態を抽出し、UI がその状態を更新するために呼び出すことができるイベントを定義できます。同じ Activity を ViewModel
を使用して作成したものを見てみましょう。
HelloCodelabActivity.kt
class HelloCodelabViewModel: ViewModel() {
// LiveData holds state which is observed by the UI
// (state flows down from ViewModel)
private val _name = MutableLiveData("")
val name: LiveData<String> = _name
// onNameChanged is an event we're defining that the UI can invoke
// (events flow up from UI)
fun onNameChanged(newName: String) {
_name.value = newName
}
}
class HelloCodeLabActivityWithViewModel : AppCompatActivity() {
private val helloViewModel by viewModels<HelloCodelabViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
binding.textInput.doAfterTextChanged {
helloViewModel.onNameChanged(it.toString())
}
helloViewModel.name.observe(this) { name ->
binding.helloText.text = "Hello, $name"
}
}
}
この例では、状態を Activity
から ViewModel
に移動しました。ViewModel では、状態は LiveData
で表されます。LiveData
は監視可能(オブザーバブル)な状態ホルダーです。つまり、誰でも状態の変化を監視できる手段を提供しています。UI では、状態が変化するたびに observe
メソッドを使用して UI を更新します。
ViewModel
は、イベント onNameChanged
も公開しています。このイベントは、UI がユーザー イベントに応答して呼び出します(たとえば、EditText
のテキストが変更されるたびにこれが発生します)。
前に説明した UI 更新ループに戻ると、この ViewModel
がどのようにイベントや状態と組み合わされるのかがわかります。
- イベント – テキスト入力が変更されると、UI によって
onNameChanged
が呼び出されます。 - 状態の更新 -
onNameChanged
が処理を行った後、_name
の状態を設定します。 - 状態の表示 –
name
のオブザーバー(1 つ以上)が呼び出され、UI に状態の変化が通知されます。
このようにコードを構造化することで、イベントが ViewModel
に向かって「上」へ流れると考えることができます。そして、イベントへの応答として、ViewModel
が処理を行い、場合によっては状態を更新します。状態が更新されるときは、Activity
に向かって「下」に流れます。
このパターンは、単方向データフローと呼ばれます。単方向データフローは、状態が下に流れ、イベントが上に流れるという設計です。このようにコードを構造化することには、以下の利点があります。
- テストの容易性 - 状態を表示する UI から状態を分離することで、ViewModel と Activity の両方を簡単にテストできます。
- 状態のカプセル化 – 状態の更新は 1 か所(
ViewModel
)でのみ行われるため、UI が複雑になっても不完全な状態更新というバグが発生する可能性が低くなります。 - UI の整合性 – オブザーバブルな状態ホルダーを使用することにより、すべての状態の更新が UI に即座に反映されます。
この方法ではコードが少し増えますが、単方向データフローを使用して複雑な状態とイベントを処理するほうが簡単で信頼性が高くなる傾向があります。
次のセクションでは、Compose で単方向データフローを使用する方法について説明します。
4. Compose と ViewModel
前のセクションでは、ViewModel
と LiveData
を使用して、Android View システムの単方向データフローについて説明しました。次は、Compose に進んで、ViewModels
を使用して Compose で単方向データフローを使用する方法を見てみましょう。
このセクションの終わりには、次のような画面が完成します。
TodoScreen のコンポーザブルを確認する
ダウンロードしたコードには、この Codelab を通じて使用や編集を行うコンポーザブルが含まれています。
TodoScreen.kt
を開き、そこにある TodoScreen
コンポーザブルを確認します。
TodoScreen.kt
@Composable
fun TodoScreen(
items: List<TodoItem>,
onAddItem: (TodoItem) -> Unit,
onRemoveItem: (TodoItem) -> Unit
) {
/* ... */
}
このコンポーザブルの表示内容を確認するには、右上の [Split] アイコン をクリックして、Android Studio のプレビュー パネルを使用します。
このコンポーザブルは、編集可能な TODO リストを表示しますが、それ自体の状態はありません。変更可能な値が状態でしたが、TodoScreen の引数はどれも変更できません。
items
– 画面に表示するアイテムの不変のリストonAddItem
– ユーザーがアイテムの追加をリクエストしたときのイベントonRemoveItem
– ユーザーがアイテムの削除をリクエストしたときのイベント
確かに、このコンポーザブルはステートレスです。渡されたアイテムリストが表示されるだけで、リストを直接編集することはできません。その代わり、変更をリクエストできる 2 つのイベント onRemoveItem
と onAddItem
が渡されます。
では、ステートレスな場合に編集可能なリストを表示するにはどうすればよいでしょうか。これは、状態ホイスティングと呼ばれる手法を使用して行います。状態ホイスティングとは、コンポーネントをステートレスにするために状態を移動するパターンです。ステートレスなコンポーザブルはテストが簡単で、通常はバグが少なく、再利用の機会を数多く提供します。
上記のパラメータの組み合わせで、呼び出し元がこのコンポーザブルから状態をホイスティングできるようになることがわかります。その仕組みを確認するために、このコンポーザブルの UI 更新ループを見てみましょう。
- イベント - ユーザーがアイテムの追加または削除をリクエストしたときに、
TodoScreen
がonAddItem
またはonRemoveItem
を呼び出します。 - 状態の更新 –
TodoScreen
の呼び出し元で状態を更新することにより、上記のイベントに応答できます。 - 状態の表示 - 状態が更新されると、新しい
items
とともにTodoScreen
が再度呼び出され、新しいアイテムが画面に表示されます。
この状態を保持する場所と方法は、呼び出し元が決定します。items
は、メモリに保存したり、Room データベースから読み取ったりするなど、どんな方法で保存しても構いません。TodoScreen
と状態の管理方法とは完全に切り離されています。
TodoActivityScreen コンポーザブルを定義する
TodoViewModel.kt
を開き、1 つの状態変数と 2 つのイベントを定義している ViewModel
を確認します。
TodoViewModel.kt
class TodoViewModel : ViewModel() {
// state: todoItems
private var _todoItems = MutableLiveData(listOf<TodoItem>())
val todoItems: LiveData<List<TodoItem>> = _todoItems
// event: addItem
fun addItem(item: TodoItem) {
/* ... */
}
// event: removeItem
fun removeItem(item: TodoItem) {
/* ... */
}
}
この ViewModel
を使って TodoScreen
から状態のホイスティングを行います。これによって、次のような単方向データフロー設計を作成します。
TodoScreen
を TodoActivity
に統合するために、TodoActivity.kt
を開き、新しい @Composable
関数 TodoActivityScreen(todoViewModel: TodoViewModel)
を定義して、それを onCreate
の中の setContent
から呼び出します。
このセクションの残りの部分では、TodoActivityScreen
を 1 ステップずつビルドします。まず、以下のような仮の状態とイベントを指定して TodoScreen
を呼び出します。
TodoActivity.kt
import androidx.compose.runtime.Composable
class TodoActivity : AppCompatActivity() {
private val todoViewModel by viewModels<TodoViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
StateCodelabTheme {
Surface {
TodoActivityScreen(todoViewModel)
}
}
}
}
}
@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
val items = listOf<TodoItem>() // in the next steps we'll complete this
TodoScreen(
items = items,
onAddItem = { }, // in the next steps we'll complete this
onRemoveItem = { } // in the next steps we'll complete this
)
}
このコンポーザブルにより、ViewModel に格納されている状態と、プロジェクトで定義済みの TodoScreen
コンポーザブルの橋渡しを行います。ViewModel
を直接取得するように TodoScreen
を変更することは可能ですが、TodoScreen
が少し再利用しにくくなります。List<TodoItem>
のような単純なパラメータを選ぶことで、TodoScreen
と状態がホイストされる場所とを切り離すことができます。
この時点でアプリを実行すると、ボタンが表示されますが、クリックしても何も起こりません。これは、まだ ViewModel
が TodoScreen
に接続されていないためです。
イベントを上に流す
必要なコンポーネント(ViewModel
、ブリッジ コンポーザブル TodoActivityScreen
、TodoScreen
)が揃ったので、すべてを接続し、単方向データフローを使用する動的なリストを表示します。
TodoActivityScreen
で、ViewModel
から addItem
と removeItem
を渡します。
TodoActivity.kt
@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
val items = listOf<TodoItem>()
TodoScreen(
items = items,
onAddItem = { todoViewModel.addItem(it) },
onRemoveItem = { todoViewModel.removeItem(it) }
)
}
TodoScreen
が onAddItem
または onRemoveItem
を呼び出すとき、ViewModel
の適切なイベントへの呼び出しを渡すことができます。
状態を下に渡す
単方向データフローのイベントを接続し終わったので、次は状態を下に渡す必要があります。
TodoActivityScreen
を編集し、observeAsState
を使用して todoItems
LiveData
を監視するようにします。
TodoActivity.kt
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
val items: List<TodoItem> by todoViewModel.todoItems.observeAsState(listOf())
TodoScreen(
items = items,
onAddItem = { todoViewModel.addItem(it) },
onRemoveItem = { todoViewModel.removeItem(it) }
)
}
この行では、LiveData
を監視し、現在の値を List<TodoItem>
として直接使用できるようにします。
1 行で多くのことを行っているので、ひとつずつ見ていきましょう。
val items: List<TodoItem>
では、List<TodoItem>
型の変数items
を宣言しています。todoViewModel.todoItems
は、ViewModel
から取得したLiveData<List<TodoItem>
です。.observeAsState
は、LiveData<T>
を監視し、それをState<T>
オブジェクトに変換して Compose が値の変更に反応できるようにします。listOf()
は、LiveData
の初期化前にnull
の結果となることを避けるための初期値です。渡されなかった場合、items
は null 値許容のList<TodoItem>?
になります。by
は、Kotlin のプロパティ デリゲート構文であり、observeAsState
のState<List<TodoItem>>
のラッピングを自動的に解除して通常のList<TodoItem>
にします。
アプリを再度実行する
アプリを再度実行すると、動的に更新されるリストが表示されます。下部にあるボタンをクリックすると新しい項目が追加され、項目をクリックすると削除されます。
このセクションでは、ViewModels
を使用して Compose で単方向データフロー設計を実現する方法を学習しました。また、状態ホイスティングと呼ばれる手法で、ステートレスなコンポーザブルを使用してステートフルな UI を表示する方法も学習しました。そして、状態とイベントの観点での動的な UI の捉え方についても学習しました。
次のセクションでは、コンポーズ可能な関数にメモリを追加する方法を学習します。
5. Compose のメモリ
ViewModel で Compose を使用して単方向データフローを作成する方法を学びました。次に、Compose が内部で状態を操作する仕組みを見てみましょう。
前のセクションでは、Compose がコンポーザブルを再度呼び出して画面が更新されることを確認しました。再コンポーズという処理です。再度 TodoScreen
を呼び出すことで、動的リストを表示することができました。
このセクションと次のセクションでは、ステートフルなコンポーザブルを作成する方法について説明します。
このセクションでは、コンポーズ可能な関数にメモリを追加する方法について説明します。コンポーズ可能な関数は、次のセクションで Compose に状態を追加するために必要となる構成要素です。
ランダム デザイン
デザイナーによるモック
このセクションのために、あなたのチームの新しいデザイナーが、最新のデザイン トレンドである「ランダム デザイン」のモックを作ってくれました。ランダム デザインの基本原則は、良質なデザインを採用し、それに一見ランダムな変更を加えて「興味をそそる」ものにすることです。
このデザインでは、アイコンの色のアルファを 0.3~0.9 に設定します。
コンポーザブルにランダムな要素を加える
まず、TodoScreen.kt
を開き、TodoRow
コンポーザブルを確認します。このコンポーザブルが、ToDo リスト内の 1 行を表します。
値が randomTint()
の新しい val iconAlpha
を定義します。これは、デザイナーの要求通り、0.3~0.9 の浮動小数点数です。そして、アイコンの色合いを設定します。
TodoScreen.kt
import androidx.compose.material.LocalContentColor
@Composable
fun TodoRow(todo: TodoItem, onItemClicked: (TodoItem) -> Unit, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.clickable { onItemClicked(todo) }
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(todo.task)
val iconAlpha = randomTint()
Icon(
imageVector = todo.icon.imageVector,
tint = LocalContentColor.current.copy(alpha = iconAlpha),
contentDescription = stringResource(id = todo.icon.contentDescription)
)
}
}
再度プレビューを確認すると、アイコンの色合いがランダムになったことがわかります。
再コンポーズの確認
再度アプリを実行して、新しいランダム デザインを試します。色合いが毎回変化することに、すぐに気が付くでしょう。デザイナーからは、ランダムさを追求しすぎだと言われています。
リストが変更されるとアイコンの色合いが変わるアプリ
何が起きているのでしょうか。再コンポーズの処理で、リストが変更されるたびに、画面上の各行に対して randomTint
が再度呼び出されていることがわかります。
再コンポーズは、新しい入力があるとコンポーザブルを再度呼び出して、コンポーズ ツリーを更新する処理です。この場合、新しいリストとともに TodoScreen
が再度呼び出されると、LazyColumn
が画面上のすべての子を再コンポーズします。次に TodoRow
が再度呼び出され、新しいランダムな色合いが作られます。
Compose はツリーを生成しますが、Android ビューシステムの使い慣れた UI ツリーとは少し異なります。UI ウィジェットのツリーではなく、コンポーザブルのツリーが生成されます。TodoScreen
は、次のような図で表すことができます。
TodoScreen のツリー
Compose が初めてコンポジションを実行するとき、呼び出されたすべてのコンポーザブルのツリーが作成されます。そして、再コンポジション中は、呼び出された新しいコンポーザブルでツリーが更新されます。
TodoRow
が再コンポーズされるたびにアイコンが更新されるのは、TodoRow
に隠れた副作用があるためです。副作用とは、コンポーズ可能な関数の実行の外部に現れる変化を指します。
Random.nextFloat()
を呼び出すと、疑似乱数生成関数で使用される内部ランダム変数が更新されます。こうして、乱数を要求するたびに Random
が異なる値を返します。
コンポーズ可能な関数にメモリを導入する
TodoRow
が再コンポーズされるたびに色合いが変わらないようにします。そのためには、前回のコンポーズで使用した色合いを覚えておく場所が必要です。Compose ではコンポジション ツリーに値を保存できるので、TodoRow
をアップデートして iconAlpha
をコンポジション ツリーに保存できるようにします。
TodoRow
を編集して、次のように randomTint
への呼び出しを remember
で囲みます。
TodoScreen.kt
val iconAlpha: Float = remember(todo.id) { randomTint() }
Icon(
imageVector = todo.icon.imageVector,
tint = LocalContentColor.current.copy(alpha = iconAlpha),
contentDescription = stringResource(id = todo.icon.contentDescription)
)
TodoRow
の新しいコンポーズ ツリーを見ると、iconAlpha
が追加されていることがわかります。
remember を使用する TodoRow のツリー
再度アプリを実行すると、リストが変更されるたびに色合いが更新されることはなくなっていることがわかります。再コンポーズが発生すると、remember
によって保存された以前の値が返されるようになっています。
remember への呼び出しを詳しく見てみると、todo.id
が引数 key
として渡されていることがわかります。
remember(todo.id) { randomTint() }
remember の呼び出しは、次の 2 つの部分で構成されます。
- キー引数 - この remember が使用する「キー」(かっこ内で渡される部分)。ここでは、キーとして
todo.id
を渡しています。 - 計算 – 記憶する新しい値を計算するラムダ(後置ラムダで渡されます)。ここでは、
randomTint()
でランダムな値を計算します。
この部分が初めてコンポーズされる際には、remember が常に randomTint
を呼び出し、次の再コンポジションのために結果を保存します。また、渡された todo.id
も記録されます。その後の再コンポーズの際には、randomTint
の呼び出しがスキップされ、新しい todo.id
が TodoRow
に渡されない限り、保存された値が返されます。
コンポーザブルの再コンポーズは、べき等である必要があります。randomTint
の呼び出しを remember
で囲むことで、ToDo アイテムが変更されない限り、再コンポーズ時のランダムの呼び出しがスキップされます。その結果、TodoRow
には副作用がなくなり、同じ入力で再コンポーズするたびに常に同じ結果が生成されて、べき等になります。
保存した値を制御可能にする
アプリを実行すると、各アイコンでランダムな色合いが表示されるようになりました。デザイナーはランダム デザインに従っていることに満足し、リリースを承認します。
ただし、チェックインする前に行うべきコードの変更が 1 つあります。今のところ、TodoRow
の呼び出し元が色合いを指定する方法がありません。たとえば、製品担当バイス プレジデントがこの画面に気付いて、リリース直前にランダム デザインを取り除く修正が求められるなど、さまざまな理由で指定方法が必要になる可能性があります。
呼び出し元がこの値を制御できるように、remember の呼び出しを新しい iconAlpha
パラメータのデフォルト引数に移動します。
@Composable
fun TodoRow(
todo: TodoItem,
onItemClicked: (TodoItem) -> Unit,
modifier: Modifier = Modifier,
iconAlpha: Float = remember(todo.id) { randomTint() }
) {
Row(
modifier = modifier
.clickable { onItemClicked(todo) }
.padding(horizontal = 16.dp)
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(todo.task)
Icon(
imageVector = todo.icon.imageVector,
tint = LocalContentColor.current.copy(alpha = iconAlpha),
contentDescription = stringResource(id = todo.icon.contentDescription)
)
}
}
これで、呼び出し元はデフォルトで同じ動作が得られます(TodoRow
が randomTint
を計算します)。そして、アルファを指定することもできます。呼び出し元が alphaTint
を制御できるようにすることで、このコンポーザブルの再利用性が向上します。別の画面では、すべてのアイコンを 0.7 のアルファで表示したくなるかもしれません。
また、ここでの remember
の使用方法には、微妙なバグも存在します。[Add random todo] のクリックを繰り返してからスクロールして、ToDo 行の一部が画面外にスクロールされるようにしてください。スクロールしていると、スクロールで画面を戻すたびにアイコンのアルファが変化します。
次のセクションでは、状態と状態ホイスティングについて掘り下げ、こういったバグを修正するために必要なツールについて説明します。
6. Compose の状態
前のセクションでは、コンポーズ可能な関数にメモリを持たせる方法を学習しました。次は、そのメモリを使用してコンポーザブルに状態を追加する方法について説明します
ToDo 入力(状態: 開いている)
ToDo 入力(状態: 閉じている)
デザイナーは、ランダム デザインからポスト マテリアルに変更しました。ToDo 入力の新しいデザインは、閉じることができるヘッダーと同じスペースを占有し、開いている状態と閉じている状態の 2 つを主な状態として備えています。テキストが空でない場合は、開いているバージョンが表示されます。
これを作るために、まず、テキストとボタンを作成します。次に、自動非表示アイコンを追加します。
UI でのテキストの編集はステートフルです。文字を入力するたびに、また選択を変更した後でも、表示されているテキストが更新されます。Android ビューシステムでは、この状態は EditText
の内部にあり、onTextChanged
リスナーを介して公開されますが、Compose は単方向データフローを使用するように設計されているため適切ではありません。
Compose の TextField
はステートレスなコンポーザブルです。変化する ToDo リストを表示する TodoScreen
と同様に、TextField
は与えられた内容を表示し、ユーザーが入力したときにイベントを発行します。
ステートフルな TextField コンポーザブルを作成する
Compose における状態を理解するために、編集可能な TextField
を表示するステートフルなコンポーネントを作成します。
まず、TodoScreen.kt
を開き、次の関数を追加します。
TodoScreen.kt
import androidx.compose.runtime.mutableStateOf
@Composable
fun TodoInputTextField(modifier: Modifier) {
val (text, setText) = remember { mutableStateOf("") }
TodoInputText(text, setText, modifier)
}
この関数は、remember
を使用して自身にメモリを追加し、メモリ内に mutableStateOf
を保存して MutableState<String>
を作成します。これは、オブザーバブルな状態ホルダーを提供する Compose の組み込みタイプです。
すぐに値とセッター イベントを TodoInputText
に渡すため、MutableState
オブジェクトをゲッターとセッターに分解します。
これで、TodoInputTextField
に内部状態を作成できました。
動作を確認するために、TodoInputTextField
と Button
を表示するコンポーザブル TodoItemInput
を定義しましょう。
TodoScreen.kt
import androidx.compose.ui.Alignment
@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
// onItemComplete is an event will fire when an item is completed by the user
Column {
Row(Modifier
.padding(horizontal = 16.dp)
.padding(top = 16.dp)
) {
TodoInputTextField(Modifier
.weight(1f)
.padding(end = 8.dp)
)
TodoEditButton(
onClick = { /* todo */ },
text = "Add",
modifier = Modifier.align(Alignment.CenterVertically)
)
}
}
}
TodoItemInput
のパラメータは、イベント onItemComplete
のみです。ユーザーが TodoItem
に入力し終わると、このイベントがトリガーされます。Compose でカスタム イベントを定義する際には、ラムダを渡すこのパターンが主に使われます。
そして、TodoScreen
コンポーザブルを修正して、プロジェクトで定義済みの背景 TodoItemInputBackground
で TodoItemInput
を呼び出すようにします。
TodoScreen.kt
@Composable
fun TodoScreen(
items: List<TodoItem>,
onAddItem: (TodoItem) -> Unit,
onRemoveItem: (TodoItem) -> Unit
) {
Column {
// add TodoItemInputBackground and TodoItem at the top of TodoScreen
TodoItemInputBackground(elevate = true, modifier = Modifier.fillMaxWidth()) {
TodoItemInput(onItemComplete = onAddItem)
}
...
TodoItemInput を試す
主な UI コンポーザブルをファイルに定義したところで、@Preview
を追加しましょう。こうすると、コンポーザブルを単独で確認できるだけでなく、ファイルを読む際に簡単にプレビューできるようになります。
TodoScreen.kt
の最後に次のプレビュー関数を追加します。
TodoScreen.kt
@Preview
@Composable
fun PreviewTodoItemInput() = TodoItemInput(onItemComplete = { })
これで、このコンポーザブルの対話的プレビュー、またはエミュレータでのコンポーザブルのデバッグを単独で行えるようになりました。
実際に行うと、編集可能なテキスト フィールドが正しく表示され、テキストを編集できるようになっています。ユーザーが文字を入力すると、状態が更新され、表示される TextField
を更新する再コンポーズがトリガーされます。
ボタンのクリックでアイテムが追加されるようにする
ここでは、[Add] ボタンで実際に TodoItem
を追加します。そのためには、TodoInputTextField
から text
にアクセスする必要があります。
TodoItemInput
のコンポジション ツリーを見ると、テキスト状態を TodoInputTextField
内に保存していることがわかります。
TodoItemInput コンポジション ツリー(組み込みコンポーザブルは省略)
この構造では、onClick
が text
の現在の値にアクセスする必要があるため、onClick
を接続できません。text
状態を TodoItemInput
に公開して、同時に、単方向データフローを使用する必要があります。
単方向データフローは、Jetpack Compose を使用する場合の高位のアーキテクチャと単一のコンポーザブルの設計との両方に適用されます。イベントが常に上に流れ、状態が常に下に流れるようにする必要があります。
つまり、状態は TodoItemInput
から下に流れ、イベントは上に流れる必要があります。
TodoItemInput の単方向データフローの図
そのためには、子コンポーザブルである TodoInputTextField
から親の TodoItemInput
に状態を移動する必要があります。
状態ホイスティングを使用した TodoItemInput コンポジション ツリー(組み込みコンポーザブルは省略)
このパターンは、状態ホイスティングと呼ばれます。コンポーザブルから状態を「ホイスティング」して(つまり引き上げて)、ステートレスにします。状態ホイスティングは、Compose で単方向データフロー設計を構築するための主なパターンです。
状態のホイスティングを行うには、まず、コンポーザブルのすべての内部状態 T
を (value: T, onValueChange: (T) -> Unit)
というパラメータペアにリファクタリングします。
TodoInputTextField
を編集して、(value, onValueChange)
パラメータを追加することで、状態をホイスティングします。
TodoScreen.kt
// TodoInputTextField with hoisted state
@Composable
fun TodoInputTextField(text: String, onTextChange: (String) -> Unit, modifier: Modifier) {
TodoInputText(text, onTextChange, modifier)
}
このコードでは、value
パラメータと onValueChange
パラメータを TodoInputTextField
に追加します。value パラメータは text
で、onValueChange
パラメータは onTextChange
です。
状態のホイスティングを行ったので、remember で保存した状態を TodoInputTextField
から削除します。
この方法でホイスティングされる状態には、次のような重要な特性があります。
- 信頼できる唯一の情報源 - 状態を複製するのではなく移動することで、text に関して信頼できる情報源を 1 つだけにすることができます。これは、バグを防ぐのに役立ちます。
- カプセル化 -
TodoItemInput
のみが状態を変更でき、他のコンポーネントはTodoItemInput
にイベントを送信できます。このようにホイスティングを行うと、複数のコンポーザブルがその状態を使用するにもかかわらず、ステートフルなコンポーザブルは 1 つだけになります。 - 共有可能 - ホイスティングされた状態は複数のコンポーザブルで不変の値として共有できます。ここでは、
TodoInputTextField
とTodoEditButton
の両方で状態を使用します。 - インターセプト可能 –
TodoItemInput
は、状態を変更する前にイベントを無視するか変更するかを決定できます。たとえば、TodoItemInput
ではユーザーが入力する「:emoji-codes:」を絵文字に変換できます。 - 分離 –
TodoInputTextField
の状態はどこにでも保存できます。たとえば、TodoInputTextField
を変更せずに、文字を入力するたびに更新される Room データベースでこの状態を戻すこともできます。
次に、TodoItemInput
に状態を追加して、それを TodoInputTextField
に渡します。
TodoScreen.kt
@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
val (text, setText) = remember { mutableStateOf("") }
Column {
Row(Modifier
.padding(horizontal = 16.dp)
.padding(top = 16.dp)
) {
TodoInputTextField(
text = text,
onTextChange = setText,
modifier = Modifier
.weight(1f)
.padding(end = 8.dp)
)
TodoEditButton(
onClick = { /* todo */ },
text = "Add",
modifier = Modifier.align(Alignment.CenterVertically)
)
}
}
}
これで状態のホイスティングを行い、テキストの現在の値を使って TodoEditButton
の動作に影響を与えることができます。コールバックを完成させ、デザインどおり、テキストが空白でない場合にのみボタンを有効(enable
)にします。
TodoScreen.kt
// edit TodoItemInput
TodoEditButton(
onClick = {
onItemComplete(TodoItem(text)) // send onItemComplete event up
setText("") // clear the internal text
},
text = "Add",
modifier = Modifier.align(Alignment.CenterVertically),
enabled = text.isNotBlank() // enable if text is not blank
)
2 つの別々のコンポーザブルで同じ状態変数 text
を使用しています。このように、状態をホイスティングすることで状態を共有できます。また、これを行いつつ、TodoItemInput
のみをステートフルなコンポーザブルにしました。
もう一度実行
アプリを再度実行すると、ToDo アイテムを追加できることがわかります。これで、コンポーザブルに状態を追加する方法と、それをホイスティングする方法を学習しました。
コードのクリーンアップ
その前に、TodoInputTextField
をインラインにします。状態のホイスティングの学習のために、これをこのセクションに追加しました。Codelab で提供されている TodoInputText
のコードを調べると、このセクションで説明したパターンに従って状態をホイスティングしていることがわかります。
完了すると、TodoItemInput
は次のようになります。
TodoScreen.kt
@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
val (text, setText) = remember { mutableStateOf("") }
Column {
Row(Modifier
.padding(horizontal = 16.dp)
.padding(top = 16.dp)
) {
TodoInputText(
text = text,
onTextChange = setText,
modifier = Modifier
.weight(1f)
.padding(end = 8.dp)
)
TodoEditButton(
onClick = {
onItemComplete(TodoItem(text))
setText("")
},
text = "Add",
modifier = Modifier.align(Alignment.CenterVertically),
enabled = text.isNotBlank()
)
}
}
}
次のセクションでは、引き続きこのデザインに取り組んでアイコンを追加します。このセクションで学習したツールを使用して、状態をホイスティングし、単方向データフローでインタラクティブな UI を作成します。
7. 状態に基づく動的 UI
前のセクションでは、コンポーザブルに状態を追加する方法と、状態ホイスティングを使用して状態を使用するコンポーザブルをステートレスにする方法を学習しました。
次は、状態に基づく動的 UI を作成してみましょう。デザイナーによるモックに戻ると、テキストが空白ではない場合にアイコン行を表示する必要があります。
ToDo 入力(状態: 開いている - テキストは空白でない) 
ToDo 入力(状態: 閉じている - テキストは空白) 
状態を iconsVisible から導出する
TodoScreen.kt
を開き、現在選択されている icon
を保持する新しい状態変数と、テキストが空白でない場合は常に true である新しい val
iconsVisible
を作成します。
TodoScreen.kt
@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
val (text, setText) = remember { mutableStateOf("") }
val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default)}
val iconsVisible = text.isNotBlank()
// ...
現在選択されているアイコンを保持する 2 つ目の状態 icon
を追加しました。
値 iconsVisible
は、TodoItemInput
に新しい状態を追加するものではありません。それを TodoItemInput
が直接変更する方法はありません。その代わり、text
の値のみに基づいています。この再コンポジションの text
の値が何でも、その値に応じて iconsVisible
が設定され、それを使って正しい UI を表示できます。
アイコンを表示するタイミングを制御するために、TodoItemInput
に別の状態を追加することもできますが、仕様を注意深く読めば、入力されたテキストに完全に基づいて、可視性が決まっていることがわかります。状態を 2 つにすると、その同期状態がすぐにずれてしまいます。
代わりに、信頼できる唯一の情報源を用意することをおすすめします。このコンポーザブルでは、text
を状態にするだけで済みます。iconsVisible
は text
に基づくようにできます。
引き続き TodoItemInput
を編集して、iconsVisible
の値に応じて AnimatedIconRow
を表示するようにします。iconsVisible
が true の場合は AnimatedIconRow
を、false の場合は 16.dp
で Spacer を表示します。
TodoScreen.kt
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
val (text, setText) = remember { mutableStateOf("") }
val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default)}
val iconsVisible = text.isNotBlank()
Column {
Row( /* ... */ ) {
/* ... */
}
if (iconsVisible) {
AnimatedIconRow(icon, setIcon, Modifier.padding(top = 8.dp))
} else {
Spacer(modifier = Modifier.height(16.dp))
}
}
}
アプリを再度実行すると、テキストを入力したときにアイコンがアニメーション表示されます。
ここでは、iconsVisible
の値に基づいてコンポジション ツリーを動的に変更します。次の図に、両方の状態のコンポジション ツリーを示します。
この種の条件付き表示ロジックは、Android ビューシステムで visibility を gone にした場合と同等です。
iconsVisible が変更されたときの TodoItemInput コンポジション ツリー
アプリを再度実行すると、アイコン行が正しく表示されるようになりますが、[Add] をクリックしても、追加済みの ToDo 行にはなりません。これは、アイコンの新しい状態を渡すようにイベントを更新していないためです。次にそれを行います。
icon を使用するようにイベントを更新する
TodoItemInput
の TodoEditButton
を編集して、onClick
リスナーで新しい状態 icon
を使用するようにします。
TodoScreen.kt
TodoEditButton(
onClick = {
onItemComplete(TodoItem(text, icon))
setIcon(TodoIcon.Default)
setText("")
},
text = "Add",
modifier = Modifier.align(Alignment.CenterVertically),
enabled = text.isNotBlank()
)
新しい状態 icon
は、onClick
リスナーで直接使用できます。また、ユーザーが TodoItem
の入力を終わったときにデフォルトにリセットします。
アプリを実行すると、アニメーション ボタン付きのインタラクティブな ToDo 入力が表示されます。以上で完了です。
imeAction を使うデザインを完成させる
デザイナーにアプリを見せると、キーボードの IME アクションから ToDo アイテムを送信する必要があると指摘されました。右下に表示される青色のボタンです。
ImeAction.Done を備えた Android キーボード
TodoInputText
では、onImeAction
イベントを使用して imeAction に応答できます。
onImeAction
の動作を TodoEditButton
とまったく同じにする必要があります。コードの複製は可能ですが、片方のイベントのみを更新しがちなので、維持管理が難しくなってゆきます。
イベントを変数に抽出して、TodoInputText
の onImeAction
と TodoEditButton
の onClick
の両方で使用できるようにしましょう。
TodoItemInput
を再度編集して、ユーザーによる送信アクションの実行を処理する新しいラムダ関数 submit
を宣言します。次に、新しく定義されたラムダ関数を TodoInputText
と TodoEditButton
の両方に渡します。
TodoScreen.kt
@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
val (text, setText) = remember { mutableStateOf("") }
val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default)}
val iconsVisible = text.isNotBlank()
val submit = {
onItemComplete(TodoItem(text, icon))
setIcon(TodoIcon.Default)
setText("")
}
Column {
Row(Modifier
.padding(horizontal = 16.dp)
.padding(top = 16.dp)
) {
TodoInputText(
text = text,
onTextChange = setText,
modifier = Modifier
.weight(1f)
.padding(end = 8.dp),
onImeAction = submit // pass the submit callback to TodoInputText
)
TodoEditButton(
onClick = submit, // pass the submit callback to TodoEditButton
text = "Add",
modifier = Modifier.align(Alignment.CenterVertically),
enabled = text.isNotBlank()
)
}
if (iconsVisible) {
AnimatedIconRow(icon, setIcon, Modifier.padding(top = 8.dp))
} else {
Spacer(modifier = Modifier.height(16.dp))
}
}
}
必要に応じて、さらにこの関数からロジックを抽出することもできますが、このコンポーザブルはかなり良くなったので、ここで終わりにしておきます。
これは Compose の大きなメリットの一つです。Kotlin で UI を宣言することで、コードを分離して再利用可能にするために必要な抽象化を行えるからです。
キーボード操作を処理するため、TextField
には次の 2 つのパラメータが用意されています。
keyboardOptions
- Done という IME アクションの表示を有効にするために使用keyboardActions
- 特定の IME アクションに応じてトリガーするアクションを指定するために使用。この例では、Done が押されたらsubmit
が呼び出され、キーボードが非表示になるようにします。
ソフトウェア キーボードの制御には LocalSoftwareKeyboardController.current
を使用します。これはテスト版の API であるため、この関数には @OptIn(ExperimentalComposeUiApi::class)
アノテーションを付ける必要があります。
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun TodoInputText(
text: String,
onTextChange: (String) -> Unit,
modifier: Modifier = Modifier,
onImeAction: () -> Unit = {}
) {
val keyboardController = LocalSoftwareKeyboardController.current
TextField(
value = text,
onValueChange = onTextChange,
colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Transparent),
maxLines = 1,
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = {
onImeAction()
keyboardController?.hide()
}),
modifier = modifier
)
}
アプリを再度実行して、新しいアイコンを試す
アプリを再度実行すると、テキストの状態の変化に応じて、アイコンの表示と非表示が自動的に切り替わるのがわかります。アイコンの選択を変更することもできます。[Add] ボタンをクリックすると、入力した値に基づいて新しい TodoItem が生成されます。
これで、Compose における状態、状態ホイスティング、状態に基づく動的 UI の構築方法について学習しました。
以降のセクションでは、状態とやり取りする再利用可能なコンポーネントの作成方法を説明します。
8. ステートレスなコンポーザブルの抽出
デザイナーが新しいデザイン トレンドに取り組んでいます。ランダム UI とポストマテリアルは終了し、今回のデザインは「ネオモダン インタラクティブ」というデザイン トレンドに沿ったものです。どんな意味かと尋ねると、わかりにくい絵文字の入った答えが返ってきましたが、ともかくモックを見てみましょう。
編集モードのモック
デザイナーによると、入力と同じ UI を使用するものの、ボタンは保存と完了の絵文字に変更するそうです。
直近のセクションの終わりで、TodoItemInput
をステートフルなコンポーザブルとして残しました。ToDo を入力するだけであれば問題ありませんが、今度はエディタであるため、状態ホイスティングをサポートする必要があります。
このセクションでは、ステートフルなコンポーザブルから状態を抽出して、ステートレスにする方法について説明します。これにより、ToDo の追加と編集の両方に同じコンポーザブルを再利用できるようになります。
TodoItemInput をステートレスなコンポーザブルに変換する
まず、TodoItemInput
から状態をホイスティングする必要があります。しかし、どこに入れればよいでしょうか。TodoScreen
に直接入れることもできますが、すでに内部状態と完了イベントで十分に機能しています。この API は変更したくありません。
代わりに、コンポーザブルを 2 つに分割して、1 つには状態を持たせ、もう 1 つはステートレスにすることができます。
TodoScreen.kt
を開いて TodoItemInput
を 2 つのコンポーザブルに分割し、ステートフルなコンポーザブルの名前を TodoItemEntryInput
に変更します。そうすることで、新しい TodoItems
の入力にのみ使用できます。
TodoScreen.kt
@Composable
fun TodoItemEntryInput(onItemComplete: (TodoItem) -> Unit) {
val (text, setText) = remember { mutableStateOf("") }
val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default)}
val iconsVisible = text.isNotBlank()
val submit = {
onItemComplete(TodoItem(text, icon))
setIcon(TodoIcon.Default)
setText("")
}
TodoItemInput(
text = text,
onTextChange = setText,
icon = icon,
onIconChange = setIcon,
submit = submit,
iconsVisible = iconsVisible
)
}
@Composable
fun TodoItemInput(
text: String,
onTextChange: (String) -> Unit,
icon: TodoIcon,
onIconChange: (TodoIcon) -> Unit,
submit: () -> Unit,
iconsVisible: Boolean
) {
Column {
Row(
Modifier
.padding(horizontal = 16.dp)
.padding(top = 16.dp)
) {
TodoInputText(
text,
onTextChange,
Modifier
.weight(1f)
.padding(end = 8.dp),
submit
)
TodoEditButton(
onClick = submit,
text = "Add",
modifier = Modifier.align(Alignment.CenterVertically),
enabled = text.isNotBlank()
)
}
if (iconsVisible) {
AnimatedIconRow(icon, onIconChange, Modifier.padding(top = 8.dp))
} else {
Spacer(modifier = Modifier.height(16.dp))
}
}
}
この変換は、Compose を使用する際に理解しておくべき重要な変換です。ステートフルなコンポーザブル TodoItemInput
を 2 つのコンポーザブルに分割しました。一方には状態があり(TodoItemEntryInput
)、もう一方はステートレス(TodoItemInput
)です。
ステートレスなコンポーザブルには UI 関連のコードがすべて含まれており、ステートフルなコンポーザブルには UI 関連のコードは含まれていません。こうすると、状態を別々に戻す必要がある場合に、UI コードを再利用できるようになります。
アプリケーションを再度実行する
アプリケーションを再度実行して、ToDo 入力が引き続き動作することを確認します。
これで、API を変更せずにステートフルなコンポーザブルからステートレスなコンポーザブルを抽出できました。
次のセクションでは、UI を状態に結合せずに、UI ロジックをさまざまな場所で再利用する方法について説明します。
9. ViewModel で状態を使用する
デザイナーによるネオモダン インタラクティブのモックを確認したところ、現在の編集アイテムを表す状態を追加する必要があるとわかりました。
編集モードのモック
ここで、このエディタに状態を追加する場所を決定する必要があります。アイテムの表示または編集を扱う別のステートフルなコンポーザブル「TodoRowOrInlineEditor
」を作成することもできますが、一度に表示したいエディタは 1 つのみです。デザインをよく見ると、編集モードのときも上部が変化しています。そのため、状態を共有できるようにするための状態ホイスティングが必要になります。
TodoActivity の状態ツリー
TodoItemEntryInput
と TodoInlineEditor
は、画面上部の入力を非表示にするためにエディタの現在の状態を知る必要があるので、少なくとも TodoScreen
はホイスティングする必要があります。画面は、編集について知る必要のあるすべてのコンポーザブルの共通の親である階層の最下位のコンポーザブルです。
しかし、エディタはリストから導出され、同時にリストを変更しているため、リストの隣にある必要があります。状態を変更可能なレベルまでホイスティングする必要があります。リストは TodoViewModel
にあるので、ここが追加する場所です。
mutableStateListOf を使用するように TodoViewModel を変換する
このセクションでは、エディタの状態を TodoViewModel
に追加します。そして、次のセクションでは、この状態を使用してインライン エディタを作成します。
同時に、ViewModel
で mutableStateListOf
を使用して、Compose をターゲットとする場合の LiveData<List>
よりも状態コードを簡素にする方法について説明します。
mutableStateListOf
を使用すると、オブザーバブルである MutableList
のインスタンスを作成できます。つまり、MutableList の場合と同じように todoItems を扱うことができ、LiveData<List>
を使用する場合のオーバーヘッドがなくなります。
TodoViewModel.kt
を開き、次のように既存の todoItems
を mutableStateListOf
に置き換えます。
TodoViewModel.kt
import androidx.compose.runtime.mutableStateListOf
class TodoViewModel : ViewModel() {
// remove the LiveData and replace it with a mutableStateListOf
//private var _todoItems = MutableLiveData(listOf<TodoItem>())
//val todoItems: LiveData<List<TodoItem>> = _todoItems
// state: todoItems
var todoItems = mutableStateListOf<TodoItem>()
private set
// event: addItem
fun addItem(item: TodoItem) {
todoItems.add(item)
}
// event: removeItem
fun removeItem(item: TodoItem) {
todoItems.remove(item)
}
}
todoItems
の宣言は短く、LiveData
バージョンと同じ動作をします。
// state: todoItems
var todoItems = mutableStateListOf<TodoItem>()
private set
private set
を指定することで、この状態オブジェクトへの書き込みは、ViewModel
内にのみ公開される private のセッターに制限されます。
新しい ViewModel を使用するように TodoActivityScreen を更新する
TodoActivity.kt
を開き、新しい ViewModel
を使用するように TodoActivityScreen
を更新します。
TodoActivity.kt
@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
TodoScreen(
items = todoViewModel.todoItems,
onAddItem = todoViewModel::addItem,
onRemoveItem = todoViewModel::removeItem
)
}
アプリを再度実行すると、新しい ViewModel で動作していることがわかります。状態で mutableStateListOf
を使用するように変更しました。次に、エディタの状態を作成する方法を見てみましょう。
エディタの状態を定義する
次に、エディタの状態を追加します。ToDo テキストの重複を避けるため、リストを直接編集します。これを行うには、編集中の現在のテキストを保持するのではなく、現在のエディタ アイテムのリスト インデックスを保持します。
TodoViewModel.kt
を開き、エディタの状態を追加します。
現在の編集位置を保持する新しい private var currentEditPosition
を定義します。現在編集中のリスト インデックスがそこに保持されます。
次に、ゲッターを使用してコンポーズする currentEditItem
を公開します。これは通常の Kotlin 関数ですが、currentEditPosition
は State<TodoItem>
と同様に Compose の監視が可能です。
TodoViewModel.kt
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
class TodoViewModel : ViewModel() {
// private state
private var currentEditPosition by mutableStateOf(-1)
// state: todoItems
var todoItems = mutableStateListOf<TodoItem>()
private set
// state
val currentEditItem: TodoItem?
get() = todoItems.getOrNull(currentEditPosition)
// ..
コンポーザブルが currentEditItem
を呼び出すたびに、todoItems
と currentEditPosition
の両方に対する変更が監視されます。いずれかが変更された場合、コンポーザブルは再びゲッターを呼び出して新しい値を取得します。
エディタ イベントを定義する
エディタの状態を定義したので、コンポーザブルが編集を制御するために呼び出すことができるイベントを定義する必要があります。
onEditItemSelected(item: TodoItem)
、onEditDone()
、onEditItemChange(item: TodoItem)
の 3 つのイベントを作成します。
イベント onEditItemSelected
と onEditDone
は、currentEditPosition
を変更するだけです。currentEditPosition
を変更すると、Compose は currentEditItem
を読み取るすべてのコンポーザブルを再コンポーズします。
TodoViewModel.kt
class TodoViewModel : ViewModel() {
...
// event: onEditItemSelected
fun onEditItemSelected(item: TodoItem) {
currentEditPosition = todoItems.indexOf(item)
}
// event: onEditDone
fun onEditDone() {
currentEditPosition = -1
}
// event: onEditItemChange
fun onEditItemChange(item: TodoItem) {
val currentItem = requireNotNull(currentEditItem)
require(currentItem.id == item.id) {
"You can only change an item with the same id as currentEditItem"
}
todoItems[currentEditPosition] = item
}
}
イベント onEditItemChange
は、currentEditPosition
にあるリストを更新します。これにより、currentEditItem
によって返される値と todoItems
の両方が同時に変更されます。その前に、呼び出し側が間違ったアイテムを書き込まないように安全確認が行われます。
アイテムの削除時に編集を終了する
アイテムの削除時に現在のエディタを閉じるように removeItem
イベントを更新します。
TodoViewModel.kt
// event: removeItem
fun removeItem(item: TodoItem) {
todoItems.remove(item)
onEditDone() // don't keep the editor open when removing items
}
アプリを再度実行する
以上で終了です。MutableState
を使用するように ViewModel
を更新し、オブザーバブルな状態コードを簡素化する方法を確認しました。
次のセクションでは、この ViewModel
のテストを追加して、編集 UI の作成に移ります。
このセクションには多数の編集があったため、すべての変更を適用した後の TodoViewModel
の完全なリストを以下に示します。
TodoViewModel.kt
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
class TodoViewModel : ViewModel() {
private var currentEditPosition by mutableStateOf(-1)
var todoItems = mutableStateListOf<TodoItem>()
private set
val currentEditItem: TodoItem?
get() = todoItems.getOrNull(currentEditPosition)
fun addItem(item: TodoItem) {
todoItems.add(item)
}
fun removeItem(item: TodoItem) {
todoItems.remove(item)
onEditDone() // don't keep the editor open when removing items
}
fun onEditItemSelected(item: TodoItem) {
currentEditPosition = todoItems.indexOf(item)
}
fun onEditDone() {
currentEditPosition = -1
}
fun onEditItemChange(item: TodoItem) {
val currentItem = requireNotNull(currentEditItem)
require(currentItem.id == item.id) {
"You can only change an item with the same id as currentEditItem"
}
todoItems[currentEditPosition] = item
}
}
10. ViewModel で状態をテストする
ViewModel
をテストして、アプリのロジックが正しいことを確認しましょう。このセクションでは、テストを作成して、状態の State<T>
を使用するビューモデルのテスト方法を示します。
TodoViewModelTest にテストを追加する
test/
ディレクトリの TodoViewModelTest.kt
を開き、アイテムの削除のテストを追加します。
TodoViewModelTest.kt
import com.example.statecodelab.util.generateRandomTodoItem
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class TodoViewModelTest {
@Test
fun whenRemovingItem_updatesList() {
// before
val viewModel = TodoViewModel()
val item1 = generateRandomTodoItem()
val item2 = generateRandomTodoItem()
viewModel.addItem(item1)
viewModel.addItem(item2)
// during
viewModel.removeItem(item1)
// after
assertThat(viewModel.todoItems).isEqualTo(listOf(item2))
}
}
このテストでは、イベントで直接変更される State<T>
のテスト方法を示します。前のセクションでは、新しい ViewModel
を作成してから、2 つのアイテムを todoItems
に追加しています。
テスト対象となるメソッドは、リストの最初のアイテムを削除する removeItem
です。
最後に、Truth アサーションを使って、リストに 2 つ目のアイテムのみが含まれていることを確認します。
更新がテストから直接行われる場合は、テスト内で todoItems
を読み取るための追加の作業は必要ありません(ここで removeItem
を呼び出すことで行われているため)。これは単なる List<TodoItem>
です。
この ViewModel
の残りのテストは同じ基本パターンに従うため、この Codelab の演習では省略します。ViewModel
が機能するかを確認するテストをさらに追加しても、完成したモジュールの TodoViewModelTest
を開いて他のテストを確認しても構いません。
次のセクションでは、新しい編集モードを UI に追加します。
11. ステートレスなコンポーザブルを再利用する
ネオモダン インタラクティブのデザインを実装する準備ができました。作成しようとしているのは、次のようなものでした。
編集モードのモック
状態とイベントを TodoScreen に渡す
この画面に必要なすべての状態とイベントを TodoViewModel に定義したので、次は、画面を表示するために必要な状態とイベントを取得するように TodoScreen を更新します。
TodoScreen.kt
を開き、TodoScreen
のシグネチャを変更して以下を追加します。
- 現在編集中のアイテム:
currentlyEditing: TodoItem?
- 3 つの新しいイベント:
onStartEdit: (TodoItem) -> Unit
、onEditItemChange: (TodoItem) -> Unit
、onEditDone: () -> Unit
TodoScreen.kt
@Composable
fun TodoScreen(
items: List<TodoItem>,
currentlyEditing: TodoItem?,
onAddItem: (TodoItem) -> Unit,
onRemoveItem: (TodoItem) -> Unit,
onStartEdit: (TodoItem) -> Unit,
onEditItemChange: (TodoItem) -> Unit,
onEditDone: () -> Unit
) {
// ...
}
これらは、ViewModel
で定義したばかりの新しい状態とイベントです。
さらに、TodoActivity.kt
で、TodoActivityScreen
に新しい値を渡します。
TodoActivity.kt
@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
TodoScreen(
items = todoViewModel.todoItems,
currentlyEditing = todoViewModel.currentEditItem,
onAddItem = todoViewModel::addItem,
onRemoveItem = todoViewModel::removeItem,
onStartEdit = todoViewModel::onEditItemSelected,
onEditItemChange = todoViewModel::onEditItemChange,
onEditDone = todoViewModel::onEditDone
)
}
これは、新しい TodoScreen
で必要な状態とイベントを渡すだけです。
インライン エディタ コンポーザブルを定義する
ステートレスなコンポーザブル TodoItemInput
を使用してインライン エディタを定義する、新しいコンポーザブルを TodoScreen.kt
に作成します。
TodoScreen.kt
@Composable
fun TodoItemInlineEditor(
item: TodoItem,
onEditItemChange: (TodoItem) -> Unit,
onEditDone: () -> Unit,
onRemoveItem: () -> Unit
) = TodoItemInput(
text = item.task,
onTextChange = { onEditItemChange(item.copy(task = it)) },
icon = item.icon,
onIconChange = { onEditItemChange(item.copy(icon = it)) },
submit = onEditDone,
iconsVisible = true
)
このコンポーザブルはステートレスです。渡された item
の表示だけを行い、イベントを使用して状態の更新をリクエストします。以前にステートレスなコンポーザブル TodoItemInput
を抽出したので、このステートレスなコンテキストでこれを使用するのは簡単です。
この例は、ステートレスなコンポーザブルの再利用性を示しています。ヘッダーが同じ画面でステートフルな TodoItemEntryInput
を使用していても、インライン エディタでは状態を ViewModel
までホイスティングできます。
LazyColumn でインライン エディタを使用する
TodoScreen
の LazyColumn
では、現在のアイテムが編集中の場合に TodoItemInlineEditor
を表示し、それ以外の場合は、TodoRow
を表示します。
また、以前のようにアイテムを削除するのではなく、アイテムをクリックしたときに編集を開始します。
TodoScreen.kt
// fun TodoScreen()
// ...
LazyColumn(
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(top = 8.dp)
) {
items(items) { todo ->
if (currentlyEditing?.id == todo.id) {
TodoItemInlineEditor(
item = currentlyEditing,
onEditItemChange = onEditItemChange,
onEditDone = onEditDone,
onRemoveItem = { onRemoveItem(todo) }
)
} else {
TodoRow(
todo,
{ onStartEdit(it) },
Modifier.fillParentMaxWidth()
)
}
}
}
// ...
LazyColumn
コンポーザブルは、Compose においては RecyclerView
と同等です。現在の画面を表示するために必要なリストのアイテムのみを再コンポーズし、ユーザーがスクロールすると、画面から外れたコンポーザブルを破棄して、スクロールしてきた要素の新しいコンポーザブルを作成します。
新しいインタラクティブ エディタを試す
アプリを再度実行し、ToDo 行をクリックするとインタラクティブ エディタが開きます。
同じステートレスな UI コンポーザブルを使用して、ステートフルなヘッダーとインタラクティブな編集エクスペリエンスの両方を描画しています。また、これを実装する際に状態を複製することはしませんでした。
完成に近づいていますが、追加ボタンの位置がずれているため、ヘッダーを変更する必要があります。残りのステップでデザインを完成させましょう。
編集時にヘッダーを入れ替える
次に、ヘッダー デザインを完成させて、デザイナーがネオモダン インタラクティブのデザインに望んでいる絵文字ボタンに交換する方法を見てみます。
TodoScreen
コンポーザブルに戻り、エディタの状態の変化にヘッダーが応答するようにします。currentlyEditing
が null
の場合は、TodoItemEntryInput
を表示して elevation = true
を TodoItemInputBackground
に渡します。currentlyEditing
が null
でない場合は、elevation = false
を TodoItemInputBackground
に渡し、同じ背景に「Editing item」というテキストを表示します。
TodoScreen.kt
import androidx.compose.material.MaterialTheme
import androidx.compose.ui.text.style.TextAlign
@Composable
fun TodoScreen(
items: List<TodoItem>,
currentlyEditing: TodoItem?,
onAddItem: (TodoItem) -> Unit,
onRemoveItem: (TodoItem) -> Unit,
onStartEdit: (TodoItem) -> Unit,
onEditItemChange: (TodoItem) -> Unit,
onEditDone: () -> Unit
) {
Column {
val enableTopSection = currentlyEditing == null
TodoItemInputBackground(elevate = enableTopSection) {
if (enableTopSection) {
TodoItemEntryInput(onAddItem)
} else {
Text(
"Editing item",
style = MaterialTheme.typography.h6,
textAlign = TextAlign.Center,
modifier = Modifier
.align(Alignment.CenterVertically)
.padding(16.dp)
.fillMaxWidth()
)
}
}
// ..
繰り返しますが、再コンポーズ時に Compose ツリーを変更しようとしています。上部のセクションが有効になっている場合は TodoItemEntryInput
を表示し、それ以外の場合は、「Editing item」を表示する Text
コンポーザブルを表示します。
スターター コードにあった TodoItemInputBackground
では、サイズ変更と高度の変化が自動的にアニメーション化されます。そのため、このコードでは、編集モードに移行したときに状態間の変化を自動的にアニメーション化するようにします。
アプリを再度実行する
アプリを再度実行すると、編集状態と非編集状態との間の変化がアニメーション化されているのがわかります。もう少しでこのデザインが完成します。
次のセクションでは、絵文字ボタンのコードを構成する方法について説明します。
12. スロットを使用して画面のセクションを渡す
複雑な UI を表示するステートレスなコンポーザブルには、多数のパラメータが含まれる場合があります。パラメータが多すぎても、コンポーザブルを直接設定していれば、問題ありません。しかし、コンポーザブルの子を設定するためにパラメータを渡す必要がある場合があります。
デザイナーは、ネオモダン インタラクティブのデザインで [Add] ボタンを上部に残し、インライン エディタではそれを 2 つの絵文字ボタンに交換することを望んでいます。このケースに対応するために TodoItemInput
にパラメータを追加することはできますが、TodoItemInput
の役割が不明確です。
必要なのは、コンポーザブルを事前設定されたボタン セクションに取り込む方法です。そうすれば、呼び出し元は、TodoItemInput
の設定に必要なすべての状態を共有することなく、必要に応じてボタンを設定できます。
これにより、ステートレスなコンポーザブルに渡されるパラメータの数を減らせるだけでなく、パラメータの再利用性が向上します。
事前設定済みのセクションを渡すパターンは、スロットです。スロットは、コンポーザブルのパラメータであり、呼び出し元はこれを使って画面のセクションを記述できます。スロットの例は、組み込みコンポーザブル API でよく確認できます。よく使用される例としては、Scaffold
が挙げられます。
Scaffold
は、画面の topBar
、bottomBar
、本文など、マテリアル デザインで画面全体を記述するためのコンポーザブルです。
画面の各セクションを構成するために多数のパラメータを指定するのではなく、Scaffold
で任意のコンポーザブルを埋めることができるスロットを公開します。これにより、Scaffold
へのパラメータの数が減り、再利用性が向上します。カスタムの topBar
を作成する場合は、Scaffold
を使用して表示できます。
@Composable
fun Scaffold(
// ..
topBar: @Composable (() -> Unit)? = null,
bottomBar: @Composable (() -> Unit)? = null,
// ..
bodyContent: @Composable (PaddingValues) -> Unit
) {
TodoItemInput
にスロットを定義する
TodoScreen.kt
を開き、ステートレスな TodoItemInput
に buttonSlot
という @Composable () -> Unit
の新しいパラメータを定義します。
TodoScreen.kt
@Composable
fun TodoItemInput(
text: String,
onTextChange: (String) -> Unit,
icon: TodoIcon,
onIconChange: (TodoIcon) -> Unit,
submit: () -> Unit,
iconsVisible: Boolean,
buttonSlot: @Composable () -> Unit
) {
// ...
これは、呼び出し元が希望するボタンで埋めることのできる汎用スロットです。ここでは、ヘッダーとインライン エディタにさまざまなボタンを指定するために使用します。
buttonSlot
の内容を表示する
TodoEditButton
の呼び出しをスロットの内容に置き換えます。
TodoScreen.kt
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width
@Composable
fun TodoItemInput(
text: String,
onTextChange: (String) -> Unit,
icon: TodoIcon,
onIconChange: (TodoIcon) -> Unit,
submit: () -> Unit,
iconsVisible: Boolean,
buttonSlot: @Composable() () -> Unit,
) {
Column {
Row(
Modifier
.padding(horizontal = 16.dp)
.padding(top = 16.dp)
) {
TodoInputText(
text,
onTextChange,
Modifier
.weight(1f)
.padding(end = 8.dp),
submit
)
// New code: Replace the call to TodoEditButton with the content of the slot
Spacer(modifier = Modifier.width(8.dp))
Box(Modifier.align(Alignment.CenterVertically)) { buttonSlot() }
// End new code
}
if (iconsVisible) {
AnimatedIconRow(icon, onIconChange, Modifier.padding(top = 8.dp))
} else {
Spacer(modifier = Modifier.height(16.dp))
}
}
}
buttonSlot()
を直接呼び出すこともできますが、呼び出し元が垂直方向に何を渡した場合でも align
を中央にする必要があります。そのためには、基本的なコンポーザブルである Box
にスロットを置きます。
スロットを使用するようにステートフルな TodoItemEntryInput
を更新する
次に、buttonSlot
を使用するように呼び出し元を更新する必要があります。最初に TodoItemEntryInput
を次のように更新しましょう。
TodoScreen.kt
@Composable
fun TodoItemEntryInput(onItemComplete: (TodoItem) -> Unit) {
val (text, onTextChange) = remember { mutableStateOf("") }
val (icon, onIconChange) = remember { mutableStateOf(TodoIcon.Default)}
val submit = {
if (text.isNotBlank()) {
onItemComplete(TodoItem(text, icon))
onTextChange("")
onIconChange(TodoIcon.Default)
}
}
TodoItemInput(
text = text,
onTextChange = onTextChange,
icon = icon,
onIconChange = onIconChange,
submit = submit,
iconsVisible = text.isNotBlank()
) {
TodoEditButton(onClick = submit, text = "Add", enabled = text.isNotBlank())
}
}
buttonSlot
は TodoItemInput
の最後のパラメータであるため、末尾ラムダ構文を使用できます。次に、以前と同様にラムダで TodoEditButton
を呼び出します。
スロットを使用するように TodoItemInlineEditor
を更新する
リファクタリングの最後として、スロットを使用するように TodoItemInlineEditor
も変更します。
TodoScreen.kt
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.TextButton
@Composable
fun TodoItemInlineEditor(
item: TodoItem,
onEditItemChange: (TodoItem) -> Unit,
onEditDone: () -> Unit,
onRemoveItem: () -> Unit
) = TodoItemInput(
text = item.task,
onTextChange = { onEditItemChange(item.copy(task = it)) },
icon = item.icon,
onIconChange = { onEditItemChange(item.copy(icon = it)) },
submit = onEditDone,
iconsVisible = true,
buttonSlot = {
Row {
val shrinkButtons = Modifier.widthIn(20.dp)
TextButton(onClick = onEditDone, modifier = shrinkButtons) {
Text(
text = "\uD83D\uDCBE", // floppy disk
textAlign = TextAlign.End,
modifier = Modifier.width(30.dp)
)
}
TextButton(onClick = onRemoveItem, modifier = shrinkButtons) {
Text(
text = "❌",
textAlign = TextAlign.End,
modifier = Modifier.width(30.dp)
)
}
}
}
)
ここでは、名前付きパラメータとして buttonSlot
を渡しています。次に、buttonSlot
で、インライン エディタのデザインに使う 2 つのボタンを含んだ Row を作成します。
アプリを再度実行する
アプリを再度実行し、インライン エディタを試してください。
このセクションでは、スロットを使用してステートレスなコンポーザブルをカスタマイズし、呼び出し元が画面の一部を制御できるようにしました。スロットを使用することで、今後追加される可能性のあるさまざまな設計のすべてと TodoItemInput
との結合を避けています。
ステートレスなコンポーザブルにパラメータを追加して子をカスタマイズする場合は、スロットの設計が適切かどうかを評価してください。スロットは、パラメータの数を抑えながら、コンポーザブルを再利用しやすくします。
13. 完了
これで、この Codelab は終了です。Jetpack Compose アプリで単方向データフローを使用して状態を構造化する方法を学習しました。
状態とイベントを意識して Compose でステートレスなコンポーザブルを抽出する方法を学び、同じ画面の異なる状況で複雑なコンポーザブルを再利用する方法を学習しました。また、LiveData と MutableState の両方を使用して ViewModel を Compose と統合する方法も学びました。
次のステップ
この Compose パスウェイにある他の Codelab を確認してください。
サンプルアプリ
- JetNews では、単方向データフローを使用してステートフルなコンポーザブルを使い、ステートレスなコンポーザブルを使用して画面の状態を管理する方法を示しています。