Jetpack Compose で状態を使用する

1. はじめに

この Codelab では、状態の概要、使用方法、Jetpack Compose で操作する方法について説明します。

まず、状態を正確に定義します。本質的には、アプリにおける状態とは、時間とともに変化する可能性がある値すべてを指します。これは非常に広範な定義であり、Room データベースにも、クラス内の変数一つにも当てはまります。

すべての Android アプリはユーザーに状態を表示します。Android アプリの状態の例を次にいくつか示します。

  1. ネットワーク接続を確立できないときに表示されるスナックバー
  2. ブログ投稿と関連コメント
  3. ユーザーがボタンをクリックしたときに再生されるボタンの波紋アニメーション
  4. ユーザーが画像の上に描画できるステッカー

この Codelab では、Jetpack Compose を使用する際の状態の考え方と使い方を学びます。そのために、TODO アプリを作成します。この Codelab の最後には、インタラクティブで編集可能な TODO リストを表示するステートフルな UI が完成します。

b5c4dc05d1e54d5a.png

次のセクションでは「単方向データフロー」という、Compose を使用する際に状態を表示する方法と管理する方法を理解するうえでの中核となるデザイン パターンについて学習します。

学習内容

  • 単方向データフローとは
  • UI における状態とイベントの考え方
  • Compose でアーキテクチャ コンポーネントの ViewModelLiveData を使用して状態を管理する方法
  • Compose が状態を使用して画面を描画する仕組み
  • 呼び出し元に状態を移動するタイミング
  • Compose での内部状態の使用方法
  • State<T> を使用して状態を Compose と統合する方法

必要なもの

  • Android Studio Bumblebee
  • Kotlin に関する知識
  • この Codelab の前に Jetpack Compose の基本の Codelab を受講することを検討してください。
  • Compose に関する基礎知識(@Composable アノテーションなど)
  • Compose レイアウト(Row や Column など)に関する基本的な知識
  • 修飾子(Modifier.padding など)に関する基本的な知識
  • アーキテクチャ コンポーネントの ViewModelLiveData に関する基礎知識

作成するアプリの概要

  • Compose で単方向データフローを使用するインタラクティブな TODO アプリ

2. 設定方法

サンプルアプリを次のいずれかの方法でダウンロードします。

または次のコマンドを使用して、コマンドラインから GitHub リポジトリのクローンを作成します。

git clone https://github.com/googlecodelabs/android-compose-codelabs.git
cd android-compose-codelabs/StateCodelab

どちらのモジュールも、Android Studio のツールバーで実行構成を変更することでいつでも実行できます。

b059413b0cf9113a.png

Android Studio でプロジェクトを開く

  1. [Welcome to Android Studio] ウィンドウで、c01826594f360d94.png [Open an Existing Project] を選択します。
  2. [Download Location]/StateCodelab フォルダを選択します(ヒント: build.gradle が入っている StateCodelab ディレクトリを選択してください)。
  3. Android Studio にプロジェクトがインポートされたら、start モジュールと finished モジュールを実行できるかどうかテストします。

開始コードを確認する

開始コードには、次の 4 つのパッケージが含まれています。

  • examples - 単方向データフローのコンセプトを確認するためのサンプル アクティビティ。このパッケージを編集する必要はありません。
  • ui – 新しい Compose プロジェクトの開始時に Android Studio が自動生成したテーマが含まれています。このパッケージを編集する必要はありません。
  • util – プロジェクトのヘルパーコードが入っています。このパッケージを編集する必要はありません。
  • todo – 作成する ToDo 画面のコードが入っているパッケージです。このパッケージに変更を加えます。

この Codelab では、todo パッケージのファイルについて説明します。start モジュールに、これから学習するファイルがあります。

todo パッケージで提供されるファイル

  • Data.ktTodoItem の表現に使用されるデータ構造です。
  • 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 更新ループを備えています。

f415ca9336d83142.png

  • イベント – ユーザーまたはプログラムの要素によってイベントが生成されます。
  • 状態の更新 – イベント ハンドラが UI で使用される状態を変更します。
  • 状態の表示 – UI が更新され、新しい状態を表示します。

状態とイベントの相互作用を理解すれば、Compose の状態を扱えるようになります。

構造化されていない状態

Compose に取り掛かる前に、Android ビューシステムにおけるイベントと状態を見てみましょう。状態の「Hello World」として、ユーザーが名前を入力できる Hello World Activity を作成します。

879ed27ccab2eed3.gif

イベント コールバックで 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 にさらにイベントや状態を追加すると、以下のような問題が発生する可能性があります。

  1. テスト – UI の状態と Views とが絡み合って、コードのテストが難しくなる可能性があります。
  2. 不完全な状態更新 - 画面のイベントが増えると、イベントに応答して状態の一部を更新することを忘れやすくなります。その結果、UI の不整合や誤りが生じる可能性があります。
  3. 不完全な UI 更新 - 状態が変化するたびに手動で UI を更新しているため、簡単に更新を忘れてしまいます。その結果、ランダムに更新される UI に古いデータが表示されることになります。
  4. コードの複雑さ - このパターンでコーディングを行っていると、ロジックの一部を抽出することが困難になります。その結果、読むのも理解するのも困難なコードになりがちです。

単方向データフローの使用

このような構造化されていない状態に関する問題を解決するために、ViewModelLiveData を含む Android アーキテクチャ コンポーネントを導入しました。

ViewModel を使用すると、UI から状態を抽出し、UI がその状態を更新するために呼び出すことができるイベントを定義できます。同じ Activity を ViewModel を使用して作成したものを見てみましょう。

8a331b9c1b392bef.png

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 に向かって「下」に流れます。

状態は ViewModel から Activity へと下に流れ、イベントは Activity から ViewModel へと上に流れる。

このパターンは、単方向データフローと呼ばれます。単方向データフローは、状態が下に流れ、イベントが上に流れるという設計です。このようにコードを構造化することには、以下の利点があります。

  • テストの容易性 - 状態を表示する UI から状態を分離することで、ViewModel と Activity の両方を簡単にテストできます。
  • 状態のカプセル化 – 状態の更新は 1 か所(ViewModel)でのみ行われるため、UI が複雑になっても不完全な状態更新というバグが発生する可能性が低くなります。
  • UI の整合性 – オブザーバブルな状態ホルダーを使用することにより、すべての状態の更新が UI に即座に反映されます。

この方法ではコードが少し増えますが、単方向データフローを使用して複雑な状態とイベントを処理するほうが簡単で信頼性が高くなる傾向があります。

次のセクションでは、Compose で単方向データフローを使用する方法について説明します。

4. Compose と ViewModel

前のセクションでは、ViewModelLiveData を使用して、Android View システムの単方向データフローについて説明しました。次は、Compose に進んで、ViewModels を使用して Compose で単方向データフローを使用する方法を見てみましょう。

このセクションの終わりには、次のような画面が完成します。

7998ef0a441d4b3.png

TodoScreen のコンポーザブルを確認する

ダウンロードしたコードには、この Codelab を通じて使用や編集を行うコンポーザブルが含まれています。

TodoScreen.kt を開き、そこにある TodoScreen コンポーザブルを確認します。

TodoScreen.kt

@Composable
fun TodoScreen(
   items: List<TodoItem>,
   onAddItem: (TodoItem) -> Unit,
   onRemoveItem: (TodoItem) -> Unit
) {
   /* ... */
}

このコンポーザブルの表示内容を確認するには、右上の [Split] アイコン 52dd4dd99bae0aaf.png をクリックして、Android Studio のプレビュー パネルを使用します。

4cedcddc3df7c5d6.png

このコンポーザブルは、編集可能な TODO リストを表示しますが、それ自体の状態はありません。変更可能な値が状態でしたが、TodoScreen の引数はどれも変更できません。

  • items – 画面に表示するアイテムの不変のリスト
  • onAddItem – ユーザーがアイテムの追加をリクエストしたときのイベント
  • onRemoveItem – ユーザーがアイテムの削除をリクエストしたときのイベント

確かに、このコンポーザブルはステートレスです。渡されたアイテムリストが表示されるだけで、リストを直接編集することはできません。その代わり、変更をリクエストできる 2 つのイベント onRemoveItemonAddItem が渡されます。

では、ステートレスな場合に編集可能なリストを表示するにはどうすればよいでしょうか。これは、状態ホイスティングと呼ばれる手法を使用して行います。状態ホイスティングとは、コンポーネントをステートレスにするために状態を移動するパターンです。ステートレスなコンポーザブルはテストが簡単で、通常はバグが少なく、再利用の機会を数多く提供します。

上記のパラメータの組み合わせで、呼び出し元がこのコンポーザブルから状態をホイスティングできるようになることがわかります。その仕組みを確認するために、このコンポーザブルの UI 更新ループを見てみましょう。

  • イベント - ユーザーがアイテムの追加または削除をリクエストしたときに、TodoScreenonAddItem または 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 から状態のホイスティングを行います。これによって、次のような単方向データフロー設計を作成します。

f555d7b9be40144c.png

TodoScreenTodoActivity に統合するために、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 と状態がホイストされる場所とを切り離すことができます。

この時点でアプリを実行すると、ボタンが表示されますが、クリックしても何も起こりません。これは、まだ ViewModelTodoScreen に接続されていないためです。

a195c5b4d2a5ea0f.png

イベントを上に流す

必要なコンポーネント(ViewModel、ブリッジ コンポーザブル TodoActivityScreenTodoScreen)が揃ったので、すべてを接続し、単方向データフローを使用する動的なリストを表示します。

TodoActivityScreen で、ViewModel から addItemremoveItem を渡します。

TodoActivity.kt

@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
   val items = listOf<TodoItem>()
   TodoScreen(
       items = items,
       onAddItem = { todoViewModel.addItem(it) },
       onRemoveItem = { todoViewModel.removeItem(it) }
   )
}

TodoScreenonAddItem または 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 のプロパティ デリゲート構文であり、observeAsStateState<List<TodoItem>> のラッピングを自動的に解除して通常の List<TodoItem> にします。

アプリを再度実行する

アプリを再度実行すると、動的に更新されるリストが表示されます。下部にあるボタンをクリックすると新しい項目が追加され、項目をクリックすると削除されます。

7998ef0a441d4b3.png

このセクションでは、ViewModels を使用して Compose で単方向データフロー設計を実現する方法を学習しました。また、状態ホイスティングと呼ばれる手法で、ステートレスなコンポーザブルを使用してステートフルな UI を表示する方法も学習しました。そして、状態イベントの観点での動的な UI の捉え方についても学習しました。

次のセクションでは、コンポーズ可能な関数にメモリを追加する方法を学習します。

5. Compose のメモリ

ViewModel で Compose を使用して単方向データフローを作成する方法を学びました。次に、Compose が内部で状態を操作する仕組みを見てみましょう。

前のセクションでは、Compose がコンポーザブルを再度呼び出して画面が更新されることを確認しました。再コンポーズという処理です。再度 TodoScreen を呼び出すことで、動的リストを表示することができました。

このセクションと次のセクションでは、ステートフルなコンポーザブルを作成する方法について説明します。

このセクションでは、コンポーズ可能な関数にメモリを追加する方法について説明します。コンポーズ可能な関数は、次のセクションで Compose に状態を追加するために必要となる構成要素です。

ランダム デザイン

デザイナーによるモック

40a46273d161497a.png

このセクションのために、あなたのチームの新しいデザイナーが、最新のデザイン トレンドである「ランダム デザイン」のモックを作ってくれました。ランダム デザインの基本原則は、良質なデザインを採用し、それに一見ランダムな変更を加えて「興味をそそる」ものにすることです。

このデザインでは、アイコンの色のアルファを 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)
       )
   }
}

再度プレビューを確認すると、アイコンの色合いがランダムになったことがわかります。

cdb483885e713651.png

再コンポーズの確認

再度アプリを実行して、新しいランダム デザインを試します。色合いが毎回変化することに、すぐに気が付くでしょう。デザイナーからは、ランダムさを追求しすぎだと言われています。

リストが変更されるとアイコンの色合いが変わるアプリ

2e53e9411aeee11e.gif

何が起きているのでしょうか。再コンポーズの処理で、リストが変更されるたびに、画面上の各行に対して randomTint が再度呼び出されていることがわかります。

再コンポーズは、新しい入力があるとコンポーザブルを再度呼び出して、コンポーズ ツリーを更新する処理です。この場合、新しいリストとともに TodoScreen が再度呼び出されると、LazyColumn が画面上のすべての子を再コンポーズします。次に TodoRow が再度呼び出され、新しいランダムな色合いが作られます。

Compose はツリーを生成しますが、Android ビューシステムの使い慣れた UI ツリーとは少し異なります。UI ウィジェットのツリーではなく、コンポーザブルのツリーが生成されます。TodoScreen は、次のような図で表すことができます。

TodoScreen のツリー

6f5faa4342c63d88.png

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 のツリー

コンポーズ ツリー内で TodoRow の新しい子として iconAlpha が示されている図

再度アプリを実行すると、リストが変更されるたびに色合いが更新されることはなくなっていることがわかります。再コンポーズが発生すると、remember によって保存された以前の値が返されるようになっています。

remember への呼び出しを詳しく見てみると、todo.id が引数 key として渡されていることがわかります。

remember(todo.id) { randomTint() }

remember の呼び出しは、次の 2 つの部分で構成されます。

  1. キー引数 - この remember が使用する「キー」(かっこ内で渡される部分)。ここでは、キーとして todo.id を渡しています。
  2. 計算 – 記憶する新しい値を計算するラムダ(後置ラムダで渡されます)。ここでは、randomTint() でランダムな値を計算します。

この部分が初めてコンポーズされる際には、remember が常に randomTint を呼び出し、次の再コンポジションのために結果を保存します。また、渡された todo.id も記録されます。その後の再コンポーズの際には、randomTint の呼び出しがスキップされ、新しい todo.idTodoRow に渡されない限り、保存された値が返されます。

コンポーザブルの再コンポーズは、べき等である必要があります。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)
        )
   }
}

これで、呼び出し元はデフォルトで同じ動作が得られます(TodoRowrandomTint を計算します)。そして、アルファを指定することもできます。呼び出し元が alphaTint を制御できるようにすることで、このコンポーザブルの再利用性が向上します。別の画面では、すべてのアイコンを 0.7 のアルファで表示したくなるかもしれません。

また、ここでの remember の使用方法には、微妙なバグも存在します。[Add random todo] のクリックを繰り返してからスクロールして、ToDo 行の一部が画面外にスクロールされるようにしてください。スクロールしていると、スクロールで画面を戻すたびにアイコンのアルファが変化します。

次のセクションでは、状態と状態ホイスティングについて掘り下げ、こういったバグを修正するために必要なツールについて説明します。

6. Compose の状態

前のセクションでは、コンポーズ可能な関数にメモリを持たせる方法を学習しました。次は、そのメモリを使用してコンポーザブルに状態を追加する方法について説明します

ToDo 入力(状態: 開いている)721446d6a55fcaba.png

ToDo 入力(状態: 閉じている)6f46071227df3625.png

デザイナーは、ランダム デザインからポスト マテリアルに変更しました。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 に内部状態を作成できました。

動作を確認するために、TodoInputTextFieldButton を表示するコンポーザブル 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 コンポーザブルを修正して、プロジェクトで定義済みの背景 TodoItemInputBackgroundTodoItemInput を呼び出すようにします。

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 を更新する再コンポーズがトリガーされます。

対話的な状態を使って実行される PreviewTodoItemInput が表示されている

ボタンのクリックでアイテムが追加されるようにする

ここでは、[Add] ボタンで実際に TodoItem を追加します。そのためには、TodoInputTextField から text にアクセスする必要があります。

TodoItemInput のコンポジション ツリーを見ると、テキスト状態を TodoInputTextField 内に保存していることがわかります。

TodoItemInput コンポジション ツリー(組み込みコンポーザブルは省略)

ツリー: 子として TodoInputTextField と TodoEditButton を持つ TodoItemInput状態の text は TodoInputTextField の子

この構造では、onClicktext の現在の値にアクセスする必要があるため、onClick を接続できません。text 状態を TodoItemInput に公開して、同時に、単方向データフローを使用する必要があります。

単方向データフローは、Jetpack Compose を使用する場合の高位のアーキテクチャと単一のコンポーザブルの設計との両方に適用されます。イベントが常に上に流れ、状態が常に下に流れるようにする必要があります。

つまり、状態は TodoItemInput から下に流れ、イベントは上に流れる必要があります。

TodoItemInput の単方向データフローの図

図: TodoItemInput が上にあり、状態は下の TodoInputTextField に流れるイベントは TodoInputTextField から上の TodoItemInput に流れる

そのためには、子コンポーザブルである TodoInputTextField から親の TodoItemInput に状態を移動する必要があります。

状態ホイスティングを使用した TodoItemInput コンポジション ツリー(組み込みコンポーザブルは省略)

e2ccddf8af39d228.png

このパターンは、状態ホイスティングと呼ばれます。コンポーザブルから状態を「ホイスティング」して(つまり引き上げて)、ステートレスにします。状態ホイスティングは、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 つだけになります。
  • 共有可能 - ホイスティングされた状態は複数のコンポーザブルで不変の値として共有できます。ここでは、TodoInputTextFieldTodoEditButton の両方で状態を使用します。
  • インターセプト可能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 アイテムを追加できることがわかります。これで、コンポーザブルに状態を追加する方法と、それをホイスティングする方法を学習しました。

767719165c35039e.png

コードのクリーンアップ

その前に、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 入力(状態: 開いている - テキストは空白でない) 721446d6a55fcaba.png

ToDo 入力(状態: 閉じている - テキストは空白) 6f46071227df3625.png

状態を 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 を状態にするだけで済みます。iconsVisibletext に基づくようにできます。

引き続き 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 コンポジション ツリー

ceb75cf0f13a1590.png

アプリを再度実行すると、アイコン行が正しく表示されるようになりますが、[Add] をクリックしても、追加済みの ToDo 行にはなりません。これは、アイコンの新しい状態を渡すようにイベントを更新していないためです。次にそれを行います。

icon を使用するようにイベントを更新する

TodoItemInputTodoEditButton を編集して、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 入力が表示されます。以上で完了です。

3d8320f055510332.gif

imeAction を使うデザインを完成させる

デザイナーにアプリを見せると、キーボードの IME アクションから ToDo アイテムを送信する必要があると指摘されました。右下に表示される青色のボタンです。

ImeAction.Done を備えた Android キーボード

6ee2444445ec12be.png

TodoInputText では、onImeAction イベントを使用して imeAction に応答できます。

onImeAction の動作を TodoEditButton とまったく同じにする必要があります。コードの複製は可能ですが、片方のイベントのみを更新しがちなので、維持管理が難しくなってゆきます。

イベントを変数に抽出して、TodoInputTextonImeActionTodoEditButtononClick の両方で使用できるようにしましょう。

TodoItemInput を再度編集して、ユーザーによる送信アクションの実行を処理する新しいラムダ関数 submit を宣言します。次に、新しく定義されたラムダ関数を TodoInputTextTodoEditButton の両方に渡します。

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 が再利用されますが、エディタがリストに埋め込まれています。

デザイナーによると、入力と同じ 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 で状態を使用する

デザイナーによるネオモダン インタラクティブのモックを確認したところ、現在の編集アイテムを表す状態を追加する必要があるとわかりました。

編集モードのモック

編集モードでは入力モードと同じ UI が再利用されますが、エディタがリストに埋め込まれています。

ここで、このエディタに状態を追加する場所を決定する必要があります。アイテムの表示または編集を扱う別のステートフルなコンポーザブル「TodoRowOrInlineEditor」を作成することもできますが、一度に表示したいエディタは 1 つのみです。デザインをよく見ると、編集モードのときも上部が変化しています。そのため、状態を共有できるようにするための状態ホイスティングが必要になります。

TodoActivity の状態ツリー

d32f2646a3f5ce65.png

TodoItemEntryInputTodoInlineEditor は、画面上部の入力を非表示にするためにエディタの現在の状態を知る必要があるので、少なくとも TodoScreen はホイスティングする必要があります。画面は、編集について知る必要のあるすべてのコンポーザブルの共通の親である階層の最下位のコンポーザブルです。

しかし、エディタはリストから導出され、同時にリストを変更しているため、リストの隣にある必要があります。状態を変更可能なレベルまでホイスティングする必要があります。リストは TodoViewModel にあるので、ここが追加する場所です。

mutableStateListOf を使用するように TodoViewModel を変換する

このセクションでは、エディタの状態を TodoViewModel に追加します。そして、次のセクションでは、この状態を使用してインライン エディタを作成します。

同時に、ViewModelmutableStateListOf を使用して、Compose をターゲットとする場合の LiveData<List> よりも状態コードを簡素にする方法について説明します。

mutableStateListOf を使用すると、オブザーバブルである MutableList のインスタンスを作成できます。つまり、MutableList の場合と同じように todoItems を扱うことができ、LiveData<List> を使用する場合のオーバーヘッドがなくなります。

TodoViewModel.kt を開き、次のように既存の todoItemsmutableStateListOf に置き換えます。

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 関数ですが、currentEditPositionState<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 を呼び出すたびに、todoItemscurrentEditPosition の両方に対する変更が監視されます。いずれかが変更された場合、コンポーザブルは再びゲッターを呼び出して新しい値を取得します。

エディタ イベントを定義する

エディタの状態を定義したので、コンポーザブルが編集を制御するために呼び出すことができるイベントを定義する必要があります。

onEditItemSelected(item: TodoItem)onEditDone()onEditItemChange(item: TodoItem) の 3 つのイベントを作成します。

イベント onEditItemSelectedonEditDone は、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. ステートレスなコンポーザブルを再利用する

ネオモダン インタラクティブのデザインを実装する準備ができました。作成しようとしているのは、次のようなものでした。

編集モードのモック

編集モードでは入力モードと同じ UI が再利用されますが、エディタがリストに埋め込まれています。

状態とイベントを TodoScreen に渡す

この画面に必要なすべての状態とイベントを TodoViewModel に定義したので、次は、画面を表示するために必要な状態とイベントを取得するように TodoScreen を更新します。

TodoScreen.kt を開き、TodoScreen のシグネチャを変更して以下を追加します。

  • 現在編集中のアイテム: currentlyEditing: TodoItem?
  • 3 つの新しいイベント:

onStartEdit: (TodoItem) -> UnitonEditItemChange: (TodoItem) -> UnitonEditDone: () -> 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 でインライン エディタを使用する

TodoScreenLazyColumn では、現在のアイテムが編集中の場合に 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 行をクリックするとインタラクティブ エディタが開きます。

Codelab のこの時点のアプリを示す画像

同じステートレスな UI コンポーザブルを使用して、ステートフルなヘッダーとインタラクティブな編集エクスペリエンスの両方を描画しています。また、これを実装する際に状態を複製することはしませんでした。

完成に近づいていますが、追加ボタンの位置がずれているため、ヘッダーを変更する必要があります。残りのステップでデザインを完成させましょう。

編集時にヘッダーを入れ替える

次に、ヘッダー デザインを完成させて、デザイナーがネオモダン インタラクティブのデザインに望んでいる絵文字ボタンに交換する方法を見てみます。

TodoScreen コンポーザブルに戻り、エディタの状態の変化にヘッダーが応答するようにします。currentlyEditingnull の場合は、TodoItemEntryInput を表示して elevation = trueTodoItemInputBackground に渡します。currentlyEditingnull でない場合は、elevation = falseTodoItemInputBackground に渡し、同じ背景に「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 では、サイズ変更と高度の変化が自動的にアニメーション化されます。そのため、このコードでは、編集モードに移行したときに状態間の変化を自動的にアニメーション化するようにします。

アプリを再度実行する

99c4d82c8df52606.gif

アプリを再度実行すると、編集状態と非編集状態との間の変化がアニメーション化されているのがわかります。もう少しでこのデザインが完成します。

次のセクションでは、絵文字ボタンのコードを構成する方法について説明します。

12. スロットを使用して画面のセクションを渡す

複雑な UI を表示するステートレスなコンポーザブルには、多数のパラメータが含まれる場合があります。パラメータが多すぎても、コンポーザブルを直接設定していれば、問題ありません。しかし、コンポーザブルの子を設定するためにパラメータを渡す必要がある場合があります。

ツールバーに [Add] ボタン、インライン エディタに絵文字ボタンがあるデザインを表示

デザイナーは、ネオモダン インタラクティブのデザインで [Add] ボタンを上部に残し、インライン エディタではそれを 2 つの絵文字ボタンに交換することを望んでいます。このケースに対応するために TodoItemInput にパラメータを追加することはできますが、TodoItemInput の役割が不明確です。

必要なのは、コンポーザブルを事前設定されたボタン セクションに取り込む方法です。そうすれば、呼び出し元は、TodoItemInput の設定に必要なすべての状態を共有することなく、必要に応じてボタンを設定できます。

これにより、ステートレスなコンポーザブルに渡されるパラメータの数を減らせるだけでなく、パラメータの再利用性が向上します。

事前設定済みのセクションを渡すパターンは、スロットです。スロットは、コンポーザブルのパラメータであり、呼び出し元はこれを使って画面のセクションを記述できます。スロットの例は、組み込みコンポーザブル API でよく確認できます。よく使用される例としては、Scaffold が挙げられます。

Scaffold は、画面の topBarbottomBar、本文など、マテリアル デザインで画面全体を記述するためのコンポーザブルです。

画面の各セクションを構成するために多数のパラメータを指定するのではなく、Scaffold で任意のコンポーザブルを埋めることができるスロットを公開します。これにより、Scaffold へのパラメータの数が減り、再利用性が向上します。カスタムの topBar を作成する場合は、Scaffold を使用して表示できます。

@Composable
fun Scaffold(
   // ..
   topBar: @Composable (() -> Unit)? = null,
   bottomBar: @Composable (() -> Unit)? = null,
   // ..
   bodyContent: @Composable (PaddingValues) -> Unit
) {

TodoItemInput にスロットを定義する

TodoScreen.kt を開き、ステートレスな TodoItemInputbuttonSlot という @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())
   }
}

buttonSlotTodoItemInput の最後のパラメータであるため、末尾ラムダ構文を使用できます。次に、以前と同様にラムダで 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 を作成します。

アプリを再度実行する

アプリを再度実行し、インライン エディタを試してください。

ae3f79834a615ed0.gif

このセクションでは、スロットを使用してステートレスなコンポーザブルをカスタマイズし、呼び出し元が画面の一部を制御できるようにしました。スロットを使用することで、今後追加される可能性のあるさまざまな設計のすべてと TodoItemInput との結合を避けています。

ステートレスなコンポーザブルにパラメータを追加して子をカスタマイズする場合は、スロットの設計が適切かどうかを評価してください。スロットは、パラメータの数を抑えながら、コンポーザブルを再利用しやすくします。

13. 完了

これで、この Codelab は終了です。Jetpack Compose アプリで単方向データフローを使用して状態を構造化する方法を学習しました。

状態とイベントを意識して Compose でステートレスなコンポーザブルを抽出する方法を学び、同じ画面の異なる状況で複雑なコンポーザブルを再利用する方法を学習しました。また、LiveData と MutableState の両方を使用して ViewModel を Compose と統合する方法も学びました。

次のステップ

この Compose パスウェイにある他の Codelab を確認してください。

サンプルアプリ

  • JetNews では、単方向データフローを使用してステートフルなコンポーザブルを使い、ステートレスなコンポーザブルを使用して画面の状態を管理する方法を示しています。

リファレンス ドキュメント