Jetpack Compose への移行

1. はじめに

Compose と View システムは併用できます。

この Codelab では、Sunflower の植物詳細画面の一部を Compose に移行します。実際のアプリを Compose に移行してみるために、プロジェクトのコピーを作成しました。

この Codelab を終了すると、必要に応じて移行を続行し、Sunflower の残りの画面を変換できるようになります。

この Codelab の学習を進める際のサポートについて詳しくは、次の Code-Along 動画をご覧ください。

学習内容

この Codelab では、以下について学びます。

  • さまざまな移行方法
  • アプリを段階的に Compose に移行する方法
  • ビューを使用して作成した既存の画面に Compose を追加する方法
  • Compose 内からビューを使用する方法
  • Compose でテーマを作成する方法
  • ビューと Compose の両方で作成された混合画面をテストする方法

前提条件

  • ラムダを含む Kotlin 構文の使用経験。
  • Compose の基本に関する知識。

必要なもの

2. 移行戦略

Jetpack Compose は、ビューの相互運用性を最初から考慮して設計されています。Compose に移行する場合については、アプリが完全に Compose に移行されるまで、コードベースに Compose とビューを共存させる増分移行をおすすめします。

推奨される移行戦略は次のとおりです。

  1. Compose を使用して新しい画面を作成します。
  2. 機能を構築しながら、再利用可能な要素を特定し、一般的な UI コンポーネントのライブラリの作成を開始します。
  3. 既存の機能を 1 画面ずつ置き換えます。

Compose を使用して新しい画面を作成する

Compose を使用した画面全体を含む新しい機能の構築は、Compose の導入を促進する最良の方法です。この戦略で機能を追加することで、企業のビジネスニーズに対応しながら Compose のメリットを活用できます。

新機能には画面全体が含まれることがあり、その場合は画面全体が Compose に含まれます。フラグメント ベースのナビゲーションを使用する場合は、新しいフラグメントを作成し、そのコンテンツを Compose に追加します。

既存の画面に新しい機能を導入することもできます。この場合、ビューと Compose が同じ画面に併存します。たとえば、追加する機能が RecyclerView の新しいビュータイプであるとします。その場合、新しいビュータイプは Compose に存在し、他のアイテムは同じ状態で保持されます。

一般的な UI コンポーネントのライブラリを作成する

Compose を使用して機能を作成する場合、結果としてコンポーネントのライブラリを構築することになります。そのため、アプリ全体で再利用を促進するために再利用可能なコンポーネントを特定し、共有コンポーネントに信頼できる唯一の情報源を提供する必要があります。作成した機能は、このライブラリに依存させることができます。

既存の機能を Compose で置き換える

新しい機能を構築するだけでなく、アプリの既存の機能を Compose に段階的に移行する必要があるとします。このためのアプローチは任意ですが、次のような方法をおすすめします。

  1. シンプルな画面 - ウェルカム画面、確認画面、設定画面など、少数の UI 要素が配置されており、動的に生成されるアプリ内のシンプルな画面。数行のコードで実行できるため、Compose に移行する際におすすめできる方法です。
  2. 混在ビュー画面と Compose 画面 - すでに少しの Compose コードが含まれている画面も、要素を 1 つずつ順に移行できるため適しています。Compose にサブツリーのみの画面がある場合は、UI 全体が Compose に配置されるまで、ツリーの他の部分を移行できます。これは移行のボトムアップ アプローチと呼ばれます。

ビューと Compose の混合 UI を Compose に移行するボトムアップ アプローチ

この Codelab のアプローチ

この Codelab では、Compose とビューを併用して、Sunflower の植物詳細画面を Compose に段階的に移行します。そうすることで、必要に応じて移行を続行できる十分な知識が身につきます。

3. 設定方法

コードを取得する

次のコマンドで、GitHub から Codelab のコードを取得します。

$ git clone https://github.com/android/codelab-android-compose

または、リポジトリを ZIP ファイルとしてダウンロードすることもできます。

サンプルアプリの実行

ダウンロードしたコードには、利用可能なすべての Compose Codelab のコードが含まれています。この Codelab を完了するには、Android Studio 内で MigrationCodelab プロジェクトを開きます。

この Codelab では、Sunflower の植物詳細画面を Compose に移行します。植物のリスト画面に表示されている植物のいずれかをタップすると、植物詳細画面が開きます。

bb6fcf50b2899894.png

プロジェクトの設定

このプロジェクトには複数の git ブランチがあります。

  • main ブランチは Codelab の出発点です。
  • end にはこの Codelab の解答があります。

main ブランチのコードから始め、ご自身のペースで Codelab を進めることをおすすめします。

Codelab の途中には、プロジェクトに追加する必要があるコード スニペットを記載しています。場所によってはコードを削除する必要もありますが、この部分はコード スニペットのコメントに明示的に記載されています。

git を使用して end ブランチを取得するには、cdMigrationCodelab プロジェクトのディレクトリに移動してから、次のコマンドを使用します。

$ git checkout end

または、次の場所から解答コードをダウンロードします。

よくある質問

4. Sunflower での Compose

Compose は、main ブランチからダウンロードしたコードにすでに追加されています。しかし、動作させるために必要なものを確認しましょう。

アプリレベルの build.gradle ファイルを開くと、Compose の依存関係がインポートされ、Android Studio で buildFeatures { compose true } フラグを使用して Compose が扱えるようになる仕組みを確認できます。

app/build.gradle

android {
    //...
    kotlinOptions {
        jvmTarget = '1.8'
    }
    buildFeatures {
        //...
        compose true
    }
    composeOptions {
        kotlinCompilerExtensionVersion '1.3.2'
    }
}

dependencies {
    //...
    // Compose
    def composeBom = platform('androidx.compose:compose-bom:2024.09.02')
    implementation(composeBom)
    androidTestImplementation(composeBom)

    implementation "androidx.compose.runtime:runtime"
    implementation "androidx.compose.ui:ui"
    implementation "androidx.compose.foundation:foundation"
    implementation "androidx.compose.foundation:foundation-layout"
    implementation "androidx.compose.material3:material3"
    implementation "androidx.compose.runtime:runtime-livedata"
    implementation "androidx.compose.ui:ui-tooling"
    //...
}

これらの依存関係のバージョンは、プロジェクト レベルの build.gradle ファイルで定義されています。

5. Compose の使用を開始する

植物詳細画面で、画面の全体構造を維持したまま、植物の説明を Compose に移行します。

UI をレンダリングするために、Compose はホストのアクティビティまたはフラグメントを必要とします。Sunflower では、すべての画面がフラグメントを使用しているため、ComposeView を使用します。これは、setContent メソッドを使用して Compose UI コンテンツをホストできる Android View です。

XML コードの削除

移行を始めましょう。fragment_plant_detail.xml を開き、次の操作を行います。

  1. コードビューに切り替えます。
  2. NestedScrollView 内の ConstraintLayout コードとネストされた 4 つの TextView を削除します(この Codelab では、個々のアイテムを移行する際に XML コードを比較して参照するため、コードをコメントアウトすると便利です)。
  3. 代わりに Compose コードをホストする ComposeView を追加し、compose_view をビュー ID とします。

fragment_plant_detail.xml

<androidx.core.widget.NestedScrollView
    android:id="@+id/plant_detail_scrollview"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:clipToPadding="false"
    android:paddingBottom="@dimen/fab_bottom_padding"
    app:layout_behavior="@string/appbar_scrolling_view_behavior">

    <!-- Step 2) Comment out ConstraintLayout and its children ->
    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_margin="@dimen/margin_normal">

        <TextView
            android:id="@+id/plant_detail_name"
        ...
    
    </androidx.constraintlayout.widget.ConstraintLayout>
    <!-- End Step 2) Comment out until here ->

    <!-- Step 3) Add a ComposeView to host Compose code ->
    <androidx.compose.ui.platform.ComposeView
        android:id="@+id/compose_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

</androidx.core.widget.NestedScrollView>

Compose コードの追加

これで、植物詳細画面の Compose への移行を開始する準備が整いました。

この Codelab では、plantdetail フォルダの PlantDetailDescription.kt ファイルに Compose コードを追加します。このファイルを開いて、"Hello Compose" というプレースホルダ テキストがプロジェクトですでに利用可能になっていることを確認します。

PlantDetailDescription.kt

@Composable
fun PlantDetailDescription() {
    Surface {
        Text("Hello Compose")
    }  
}

前のステップで追加した ComposeView からこのコンポーザブルを呼び出して、画面に表示しましょう。PlantDetailFragment.kt を開きます。

画面はデータ バインディングを使用しているため、composeView に直接アクセスして setContent を呼び出すと、Compose コードを画面に表示できます。Sunflower はマテリアル デザインを使用しているため、MaterialTheme 内で PlantDetailDescription コンポーザブルを呼び出します。

PlantDetailFragment.kt

class PlantDetailFragment : Fragment() {
    // ...
    override fun onCreateView(...): View? {
        val binding = DataBindingUtil.inflate<FragmentPlantDetailBinding>(
            inflater, R.layout.fragment_plant_detail, container, false
        ).apply {
            // ...
            composeView.setContent {
                // You're in Compose world!
                MaterialTheme {
                    PlantDetailDescription()
                }
            }
        }
        // ...
    }
}

アプリを実行すると、画面に「Hello Compose」と表示されます。

66f3525ecf6669e0.png

6. XML からのコンポーザブルの作成

まず、植物の名前を移行しましょう。正確には、fragment_plant_detail.xml で削除した ID @+id/plant_detail_nameTextView です。XML コードは次のとおりです。

<TextView
    android:id="@+id/plant_detail_name"
    ...
    android:layout_marginStart="@dimen/margin_small"
    android:layout_marginEnd="@dimen/margin_small"
    android:gravity="center_horizontal"
    android:text="@{viewModel.plant.name}"
    android:textAppearance="?attr/textAppearanceHeadline5"
    ... />

スタイルは textAppearanceHeadline5、横方向のマージンは 8.dp であり、画面上で横方向に中央揃えされています。ただし、表示されるタイトルは、リポジトリ レイヤからの PlantDetailViewModel で公開される LiveData からモニタリングされます。

LiveData のモニタリングについては後述します。名前が利用可能であり、PlantDetailDescription.kt ファイルで作成した新しい PlantName コンポーザブルにパラメータとして渡されたとしましょう。このコンポーザブルは、後で PlantDetailDescription コンポーザブルから呼び出されます。

PlantDetailDescription.kt

@Composable
private fun PlantName(name: String) {
    Text(
        text = name,
        style = MaterialTheme.typography.headlineSmall,
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = dimensionResource(R.dimen.margin_small))
            .wrapContentWidth(Alignment.CenterHorizontally)
    )
}

@Preview
@Composable
private fun PlantNamePreview() {
    MaterialTheme {
        PlantName("Apple")
    }
}

プレビュー:

d09fe886b98bde91.png

ここで

  • Text のスタイルは MaterialTheme.typography.headlineSmall であり、XML コードの textAppearanceHeadline5 に類似しています。
  • 修飾子がテキストを装飾し、XML 版のように表示します。
  • fillMaxWidth 修飾子は、利用可能な最大幅を占有するために使用されます。この修飾子は、XML コードの layout_width 属性の match_parent 値に対応します。
  • padding 修飾子は、水平方向のパディング値 margin_small が適用されるように設定するために使用されます。これは XML の marginStart 宣言と marginEnd 宣言に対応します。margin_small 値は、dimensionResource ヘルパー関数を使用して取得される既存のディメンション リソースでもあります。
  • wrapContentWidth 修飾子を使用して、テキストを水平方向に中央揃えします。これは、XML で center_horizontalgravity を使用する場合と類似しています。

7. ViewModel と LiveData

それでは、タイトルを画面につなぎましょう。そのためには、PlantDetailViewModel を使用してデータを読み込む必要があります。そこで、Compose には ViewModelLiveData の統合が用意されています。

ViewModel

Fragment で PlantDetailViewModel のインスタンスが使用されるため、これをパラメータとして PlantDetailDescription に渡すだけで済みます。

PlantDetailDescription.kt ファイルを開き、PlantDetailViewModel パラメータを PlantDetailDescription に追加します。

PlantDetailDescription.kt

@Composable
fun PlantDetailDescription(plantDetailViewModel: PlantDetailViewModel) {
    //...
}

このコンポーザブルをフラグメントから呼び出すときに、ViewModel のインスタンスを渡します。

PlantDetailFragment.kt

class PlantDetailFragment : Fragment() {
    ...
    override fun onCreateView(...): View? {
        ...
        composeView.setContent {
            MaterialTheme {
                PlantDetailDescription(plantDetailViewModel)
            }
        }
    }
}

LiveData

これで、PlantDetailViewModelLiveData<Plant> フィールドにアクセスし、植物名を取得できるようになりました。

コンポーザブルから LiveData をモニタリングするには、LiveData.observeAsState() 関数を使用します。

LiveData が出力する値は null になる可能性があるため、その使用を null チェックでラップする必要があります。そのため、再利用性を実現するため、LiveData の使用とリッスンを異なるコンポーザブルに分割することをおすすめします。そのため、Plant 情報を表示する PlantDetailContent という新しいコンポーザブルを作成しましょう。

これらの更新により、PlantDetailDescription.kt ファイルは次のようになります。

PlantDetailDescription.kt

@Composable
fun PlantDetailDescription(plantDetailViewModel: PlantDetailViewModel) {
    // Observes values coming from the VM's LiveData<Plant> field
    val plant by plantDetailViewModel.plant.observeAsState()

    // If plant is not null, display the content
    plant?.let {
        PlantDetailContent(it)
    }
}

@Composable
fun PlantDetailContent(plant: Plant) {
    PlantName(plant.name)
}

@Preview
@Composable
private fun PlantDetailContentPreview() {
    val plant = Plant("id", "Apple", "description", 3, 30, "")
    MaterialTheme {
        PlantDetailContent(plant)
    }
}

PlantNamePreview は、PlantDetailContentPlantName を呼び出すだけであるため、変更を直接更新することを必要とすることなく変更を反映します。

3e47e682cf518c71.png

これで、プラント名が Compose に表示されるように ViewModel が接続されました。以下の各セクションでは、残りのコンポーザブルを作成して、同様の方法で ViewModel に接続します。

8. 他の XML コードの移行

UI にない情報(水やり情報、植物の説明など)を簡単に記入できるようになりました。以前と同様のアプローチで、画面の残りの部分を移行できます。

以前に fragment_plant_detail.xml から削除した水やり情報の XML コードは、ID が plant_watering_headerplant_watering の 2 つの TextView で構成されています。

<TextView
    android:id="@+id/plant_watering_header"
    ...
    android:layout_marginStart="@dimen/margin_small"
    android:layout_marginTop="@dimen/margin_normal"
    android:layout_marginEnd="@dimen/margin_small"
    android:gravity="center_horizontal"
    android:text="@string/watering_needs_prefix"
    android:textColor="?attr/colorAccent"
    android:textStyle="bold"
    ... />

<TextView
    android:id="@+id/plant_watering"
    ...
    android:layout_marginStart="@dimen/margin_small"
    android:layout_marginEnd="@dimen/margin_small"
    android:gravity="center_horizontal"
    app:wateringText="@{viewModel.plant.wateringInterval}"
    .../>

以前と同様に、PlantWatering という新しいコンポーザブルを作成し、Text コンポーザブルを追加して、水やり情報を画面に表示します。

PlantDetailDescription.kt

@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun PlantWatering(wateringInterval: Int) {
    Column(Modifier.fillMaxWidth()) {
        // Same modifier used by both Texts
        val centerWithPaddingModifier = Modifier
            .padding(horizontal = dimensionResource(R.dimen.margin_small))
            .align(Alignment.CenterHorizontally)

        val normalPadding = dimensionResource(R.dimen.margin_normal)

        Text(
            text = stringResource(R.string.watering_needs_prefix),
            color = MaterialTheme.colorScheme.primaryContainer,
            fontWeight = FontWeight.Bold,
            modifier = centerWithPaddingModifier.padding(top = normalPadding)
        )

        val wateringIntervalText = pluralStringResource(
            R.plurals.watering_needs_suffix, wateringInterval, wateringInterval
        )
        Text(
            text = wateringIntervalText,
            modifier = centerWithPaddingModifier.padding(bottom = normalPadding)
        )
    }
}

@Preview
@Composable
private fun PlantWateringPreview() {
    MaterialTheme {
        PlantWatering(7)
    }
}

プレビュー:

6f6c17085801a518.png

注意点:

  • 横方向のパディングと配置の装飾は Text コンポーザブルで共有されるため、ローカル変数(centerWithPaddingModifier)に代入することで Modifier を再利用できます。修飾子は標準の Kotlin オブジェクトであるため、このようにできます。
  • Compose の MaterialTheme は、plant_watering_header で使用されている colorAccent と完全一致しません。ひとまず、相互運用性のテーマ設定のセクションで改善する MaterialTheme.colorScheme.primaryContainer を使用しましょう。
  • Compose 1.2.1 では、pluralStringResource を使用するには ExperimentalComposeUiApi にオプトインする必要があります。Compose の今後のバージョンでは、これが不要になる可能性があります。

すべての要素をつなげて、PlantDetailContent からも PlantWatering を呼び出しましょう。最初に削除した ConstraintLayout XML コードではマージンが 16.dp でした。これを Compose コードに含める必要があります。

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_margin="@dimen/margin_normal">

PlantDetailContent で、Column を作成して名前と水やり情報をまとめて表示し、パディングとします。また、使用する背景色とテキスト色が適切になるように、その処理を行う Surface を追加します。

PlantDetailDescription.kt

@Composable
fun PlantDetailContent(plant: Plant) {
    Surface {
        Column(Modifier.padding(dimensionResource(R.dimen.margin_normal))) {
            PlantName(plant.name)
            PlantWatering(plant.wateringInterval)
        }
    }
}

プレビューを更新すると、次のように表示されます。

56626a7118ce075c.png

9. Compose コードでの View

植物の説明文を移行しましょう。fragment_plant_detail.xml のコードでは、TextViewapp:renderHtml="@{viewModel.plant.description}" を指定して、画面に表示されるテキストを XML に伝えていました。renderHtml は、PlantDetailBindingAdapters.kt ファイルにあるバインディング アダプターです。この実装では、HtmlCompat.fromHtml を使用して TextView にテキストを設定しています。

しかし、Compose は現在のところ Spanned クラスや HTML 形式のテキストの表示をサポートしていません。そのため、この制限を回避するために Compose コードで View システムの TextView を使用する必要があります。

Compose ではまだ HTML コードをレンダリングできないため、AndroidView API を使用してプログラムで TextView を作成してレンダリングの処理を行います。

AndroidView を使用すると、factory ラムダで View を作成できます。また、ビューがインフレートされた場合に後続の再コンポジションで呼び出される update ラムダも用意されています。

そのために、新しい PlantDescription コンポーザブルを作成しましょう。このコンポーザブルは、factory ラムダで TextView を構築する AndroidView を呼び出します。factory ラムダで、HTML 形式のテキストを表示する TextView を初期化してから、movementMethodLinkMovementMethod のインスタンスに設定します。最後に、update ラムダで TextView のテキストを htmlDescription に設定します。

PlantDetailDescription.kt

@Composable
private fun PlantDescription(description: String) {
    // Remembers the HTML formatted description. Re-executes on a new description
    val htmlDescription = remember(description) {
        HtmlCompat.fromHtml(description, HtmlCompat.FROM_HTML_MODE_COMPACT)
    }

    // Displays the TextView on the screen and updates with the HTML description when inflated
    // Updates to htmlDescription will make AndroidView recompose and update the text
    AndroidView(
        factory = { context ->
            TextView(context).apply {
                movementMethod = LinkMovementMethod.getInstance()
            }
        },
        update = {
            it.text = htmlDescription
        }
    )
}

@Preview
@Composable
private fun PlantDescriptionPreview() {
    MaterialTheme {
        PlantDescription("HTML<br><br>description")
    }
}

プレビュー:

deea1d191e9087b4.png

htmlDescription は、パラメータとして渡された description について、HTML 形式の説明を記憶しています。description パラメータが変更された場合、remember 内の htmlDescription コードが再度実行されます。

その結果、htmlDescription が変更された場合に、AndroidView 更新コールバックが再コンポーズされます。update ラムダ内で状態が読み取られると、再コンポーズが行われます。

PlantDescriptionPlantDetailContent コンポーザブルに追加し、HTML の説明も表示するようにプレビュー コードを変更しましょう。

PlantDetailDescription.kt

@Composable
fun PlantDetailContent(plant: Plant) {
    Surface {
        Column(Modifier.padding(dimensionResource(R.dimen.margin_normal))) {
            PlantName(plant.name)
            PlantWatering(plant.wateringInterval)
            PlantDescription(plant.description)
        }
    }
}

@Preview
@Composable
private fun PlantDetailContentPreview() {
    val plant = Plant("id", "Apple", "HTML<br><br>description", 3, 30, "")
    MaterialTheme {
        PlantDetailContent(plant)
    }
}

プレビュー:

7843a8d6c781c244.png

この時点で、元の ConstraintLayout 内のコンテンツはすべて Compose に移行しました。アプリを実行して、想定どおりに動作することを確認します。

c7021c18eb8b4d4e.gif

10. ViewCompositionStrategy

ComposeView がウィンドウからデタッチされるたびに、Compose は Composition を破棄します。フラグメントで ComposeView が使用されている場合、これは 2 つの理由で望ましくありません。

  • Composition は、Compose UI の View タイプに対するフラグメントのビュー ライフサイクルに従って、状態を保存する必要があります。
  • 遷移が発生すると、基盤となる ComposeView は分離された状態になります。ただし、これらの遷移中も Compose UI 要素は引き続き表示されます。

この動作を変更するには、適切な ViewCompositionStrategy を指定して setViewCompositionStrategy を呼び出し、代わりにフラグメントのビューのライフサイクルに従うようにします。具体的には、DisposeOnViewTreeLifecycleDestroyed 戦略を使用して、フラグメントの LifecycleOwner が破棄されたときに Composition を破棄できます。

PlantDetailFragment には遷移の出入りがあるため(詳細は nav_garden.xml をご覧ください)、後ほど Compose 内で View タイプを使用し、また、ComposeViewDisposeOnViewTreeLifecycleDestroyed 戦略を使用するようにする必要があります。ただし、フラグメントで ComposeView を使用する場合は、常にこの戦略を設定することをおすすめします

PlantDetailFragment.kt

import androidx.compose.ui.platform.ViewCompositionStrategy
...

class PlantDetailFragment : Fragment() {
    ...
    override fun onCreateView(...): View? {
        val binding = DataBindingUtil.inflate<FragmentPlantDetailBinding>(
            inflater, R.layout.fragment_plant_detail, container, false
        ).apply {
            ...
            composeView.apply {
                // Dispose the Composition when the view's LifecycleOwner
                // is destroyed
                setViewCompositionStrategy(
                    ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
                )
                setContent {
                    MaterialTheme {
                        PlantDetailDescription(plantDetailViewModel)
                    }
                }
            }
        }
        ...
    }
}

11. マテリアル テーマ設定

植物の詳細のテキスト コンテンツを Compose に移行しました。しかし、Compose では適切なテーマカラーが使用されていません。緑色を使用すべき植物名に、紫色を使用しています。

正しいテーマカラーを使用するには、独自のテーマを定義し、テーマの色を指定して、MaterialTheme をカスタマイズする必要があります。

MaterialTheme のカスタマイズ

独自のテーマを作成するには、theme パッケージの Theme.kt ファイルを開きます。Theme.kt は、コンテンツ ラムダを受け取って MaterialTheme に渡す SunflowerTheme というコンポーザブルを定義します。

まだ特に何も起こりません。次はカスタマイズします。

Theme.kt

import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable

@Composable
fun SunflowerTheme(
    content: @Composable () -> Unit
) {
    MaterialTheme(content = content)
}

MaterialTheme を使用すると、色、タイポグラフィ、シェイプをカスタマイズできます。ひとまず、Sunflower View のテーマと同じ色を指定して、色をカスタマイズします。SunflowerTheme は、darkTheme というブール値のパラメータを受け入れることもできます。これは、システムがダークモードの場合は true になり、そうでない場合は false になります。このパラメータを使用すると、現在設定されているシステムテーマに合わせて、適切な色の値を MaterialTheme に渡すことができます。

Theme.kt

@Composable
fun SunflowerTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val lightColors  = lightColorScheme(
        primary = colorResource(id = R.color.sunflower_green_500),
        primaryContainer = colorResource(id = R.color.sunflower_green_700),
        secondary = colorResource(id = R.color.sunflower_yellow_500),
        background = colorResource(id = R.color.sunflower_green_500),
        onPrimary = colorResource(id = R.color.sunflower_black),
        onSecondary = colorResource(id = R.color.sunflower_black),
    )
    val darkColors  = darkColorScheme(
        primary = colorResource(id = R.color.sunflower_green_100),
        primaryContainer = colorResource(id = R.color.sunflower_green_200),
        secondary = colorResource(id = R.color.sunflower_yellow_300),
        onPrimary = colorResource(id = R.color.sunflower_black),
        onSecondary = colorResource(id = R.color.sunflower_black),
        onBackground = colorResource(id = R.color.sunflower_black),
        surface = colorResource(id = R.color.sunflower_green_100_8pc_over_surface),
        onSurface = colorResource(id = R.color.sunflower_white),
    )
    val colors = if (darkTheme) darkColors else lightColors
    MaterialTheme(
        colorScheme = colors,
        content = content
    )
}

これを使用するには、MaterialThemeSunflowerTheme に置き換えます。たとえば、PlantDetailFragment では次のようになります。

PlantDetailFragment.kt

class PlantDetailFragment : Fragment() {
    ...
    composeView.apply {
        ...
        setContent {
            SunflowerTheme {
                PlantDetailDescription(plantDetailViewModel)
            }
        }
    }
}

PlantDetailDescription.kt ファイル内のすべてのプレビュー コンポーザブルは、次のようになります。

PlantDetailDescription.kt

@Preview
@Composable
private fun PlantDetailContentPreview() {
    val plant = Plant("id", "Apple", "HTML<br><br>description", 3, 30, "")
    SunflowerTheme {
        PlantDetailContent(plant)
    }
}

@Preview
@Composable
private fun PlantNamePreview() {
    SunflowerTheme {
        PlantName("Apple")
    }
}

@Preview
@Composable
private fun PlantWateringPreview() {
    SunflowerTheme {
        PlantWatering(7)
    }
}

@Preview
@Composable
private fun PlantDescriptionPreview() {
    SunflowerTheme {
        PlantDescription("HTML<br><br>description")
    }
}

プレビューからわかるように、色は Sunflower テーマの色と一致しています。

886d7eaea611f4eb.png

新しい関数を作成し、プレビューの uiModeConfiguration.UI_MODE_NIGHT_YES を渡すことで、UI をダークモードでプレビューすることもできます。

import android.content.res.Configuration
...

@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PlantDetailContentDarkPreview() {
    val plant = Plant("id", "Apple", "HTML<br><br>description", 3, 30, "")
    SunflowerTheme {
        PlantDetailContent(plant)
    }
}

プレビュー:

cfe11c109ff19eeb.png

アプリを実行すると、ライトモードとダークモードの両方で、移行前とまったく同じように動作します。

438d2dd9f8acac39.gif

12. テスト

植物詳細画面の一部を Compose に移行した後は、何も問題がないことを確認するためのテストが不可欠です。

Sunflower では、androidTest フォルダにある PlantDetailFragmentTest によってアプリの一部の機能がテストされます。このファイルを開き、現在のコードを確認します。

  • testPlantName は、画面上の植物の名前を確認します
  • testShareTextIntent は、共有ボタンをタップした後に正しいインテントがトリガーされることを確認します

アクティビティやフラグメントで Compose を使用する場合、ActivityScenarioRule を使用するのではなく、ActivityScenarioRuleComposeTestRule と統合する createAndroidComposeRule を使用する必要があります。これにより、Compose コードをテストできるようになります。

PlantDetailFragmentTest で、ActivityScenarioRule の使用を createAndroidComposeRule に置き換えます。テストを設定するためにアクティビティ ルールが必要な場合は、次のように createAndroidComposeRuleactivityRule 属性を使用します。

@RunWith(AndroidJUnit4::class)
class PlantDetailFragmentTest {

    @Rule
    @JvmField
    val composeTestRule = createAndroidComposeRule<GardenActivity>()
   
    ...

    @Before
    fun jumpToPlantDetailFragment() {
        populateDatabase()

        composeTestRule.activityRule.scenario.onActivity { gardenActivity ->
            activity = gardenActivity

            val bundle = Bundle().apply { putString("plantId", "malus-pumila") }
            findNavController(activity, R.id.nav_host).navigate(R.id.plant_detail_fragment, bundle)
        }
    }

    ...
}

テストを実行すると、testPlantName が失敗します。testPlantName は、TextView が画面上にあるかどうかを確認します。しかし、UI のその部分は Compose に移行済みです。そのため、代わりに Compose のアサーションを使用する必要があります。

@Test
fun testPlantName() {
    composeTestRule.onNodeWithText("Apple").assertIsDisplayed()
}

テストを実行すると、すべて合格します。

dd59138fac1740e4.png

13. 完了

これで、この Codelab は終了です。

元の Sunflower GitHub プロジェクトの compose ブランチは、植物詳細画面を Compose に完全に移行しています。この Codelab で行ったこととは別に、CollapsingToolbarLayout の動作もシミュレートします。これには以下が含まれます。

  • Compose による画像の読み込み
  • アニメーション
  • ディメンション処理の向上
  • その他

次のステップ

Compose パスウェイに関する他の Codelab をご確認ください。

参考資料