Jetpack Compose の基本

1. 始める前に

Jetpack Compose は、UI 開発を簡素化するために設計された最新のツールキットです。リアクティブ プログラミング モデルに、Kotlin プログラミング言語の簡潔さと使いやすさを組み合わせています。完全な宣言型であるため、データを UI 階層に変換する一連の関数を呼び出すことにより UI を記述します。基になるデータが変更されると、フレームワークは自動的にそれらの関数を再実行して UI 階層を更新します。

Compose アプリはコンポーズ可能な関数で構成されます。これらは単に @Composable でマークされた通常の関数であり、他のコンポーズ可能な関数を呼び出すことができます。新しい UI コンポーネントを作成するために必要なのは関数のみです。アノテーションにより、特別なサポートを関数に追加するよう Compose に指示し、時間の経過に伴う UI の更新や保守を行います。Compose では、コードを小さなチャンクに分割して構造化できます。コンポーズ可能な関数は、簡潔に「コンポーザブル」と呼ばれることもあります。

再利用可能な小さいコンポーザブルを作成すると、アプリで使用する UI 要素のライブラリを簡単に構築できます。UI 要素では、それぞれで画面の一部を処理し、個別に編集することもできます。

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

: マテリアル 3 を使用するように Codelab を更新する過程で、Code-Along はマテリアル 2 を使用します。状況によって異なる手順もあることにご注意ください。

前提条件

  • ラムダを含む Kotlin 構文の使用経験。

演習内容

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

  • Compose とは何か
  • Compose を使用して UI を作成する方法
  • コンポーズ可能な関数で状態を管理する方法
  • 効率の良いリストを作成する方法
  • アニメーションを追加する方法
  • アプリのスタイルとテーマを設定する方法

オンボーディング画面と、アニメーション化された展開可能なアイテムのリストを備えたアプリを作成します。

8d24a786bfe1a8f2.gif

必要なもの

2. 新しい Compose プロジェクトを開始する

新しい Compose プロジェクトを開始するには、Android Studio を開きます。

[Welcome to Android Studio] ウィンドウが開いている場合は、[Start a new Android Studio] プロジェクトをクリックします。Android Studio プロジェクトをすでに開いている場合は、メニューバーで [File] > [New] > [New Project] を選択します。

新しいプロジェクトの場合は、利用可能なテンプレートの中から [Empty Activity] を選択します。

d12472c6323de500.png

[Next] をクリックし、通常どおりにプロジェクトを構成して、「Basic Codelab」という名前を付けます。API レベル 21(Compose がサポートする最小 API レベル)以上の minimumSdkVersion を選択してください。

[Empty Activity] テンプレートを選択すると、プロジェクトに次のコードが生成されます。

  • このプロジェクトは、Compose を使用するようにすでに構成済みです。
  • AndroidManifest.xml ファイルが作成されます。
  • build.gradle.kts ファイルと app/build.gradle.kts ファイルには、Compose に必要なオプションと依存関係が含まれています。

プロジェクトを同期した後、MainActivity.kt を開いてコードを確認します。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BasicsCodelabTheme {
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    Greeting("Android")
                }
            }
        }
    }
}

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Text(
        text = "Hello $name!",
        modifier = modifier
    )
}

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
    BasicsCodelabTheme {
        Greeting("Android")
    }
}

次のセクションでは、各メソッドの機能と、メソッドを改良して柔軟かつ再利用可能なレイアウトを作成する方法を学びます。

Codelab の解答

この Codelab の解答コードは GitHub から入手できます。

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

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

解答コードは BasicsCodelab プロジェクトにあります。自分のペースで順を追って Codelab の学習を進め、必要と思われるときに解答を確認することをおすすめします。Codelab を進める過程で、プロジェクトに追加する必要があるコード スニペットを示します。

3.Compose の使用を始める

Android Studio によって生成された Compose 関連のさまざまなクラスとメソッドについて学習します。

コンポーズ可能な関数

コンポーズ可能な関数とは、@Composable アノテーションが付いている通常の関数です。これにより、関数は内部で他の @Composable 関数を呼び出すことができます。Greeting 関数が @Composable とマークされている例を次に示します。この関数では UI 階層の一部が生成され、指定された入力(String)がその UI 階層で表示されます。Text は、ライブラリによって提供されるコンポーズ可能な関数です。

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Text(
        text = "Hello $name!",
        modifier = modifier
    )
}

Android アプリでの Compose

Compose を使用する場合でも、Activity は依然として Android アプリへのエントリ ポイントです。このプロジェクトでは、ユーザーがアプリを開くと MainActivity が起動されます(これは AndroidManifest.xml ファイルで指定されているとおりです)。レイアウトを定義するには setContent を使用しますが、従来のビューシステムの場合のように XML ファイルを使うのではなく、内部のコンポーズ可能な関数を呼び出します。

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BasicsCodelabTheme {
                // A surface container using the 'background' color from the theme
                Surface(
                  modifier = Modifier.fillMaxSize(),
                  color = MaterialTheme.colorScheme.background
                ) {
                    Greeting("Android")
                }
            }
        }
    }
}

BasicsCodelabTheme は、コンポーズ可能な関数のスタイルを設定する手段です。詳しくは、アプリのテーマを設定するセクションをご覧ください。テキストが画面にどのように表示されるかを確認するには、エミュレータまたはデバイスでアプリを実行するか、Android Studio プレビューを使用します。

Android Studio プレビューを使用するには、パラメータのないコンポーズ可能な関数またはデフォルト パラメータを含む関数に @Preview アノテーションを付けて、プロジェクトをビルドします。MainActivity.kt ファイルには、すでに Preview Composable 関数が含まれています。同じファイルに複数のプレビューを設定し、それぞれに名前を付けることができます。

@Preview(showBackground = true, name = "Text preview")
@Composable
fun GreetingPreview() {
    BasicsCodelabTheme {
        Greeting(name = "Android")
    }
}

fb011e374b98ccff.png

[Code] eeacd000622ba9b.png が選択されている場合、プレビューが表示されないことがあります。プレビューを表示するには、[Split] 7093def1e32785b2.png をクリックします。

4. UI を微調整する

まず、Greeting に別の背景色を設定しましょう。そのためには、Text コンポーザブルを Surface でラップします。Surface は色を受け取るので、MaterialTheme.colorScheme.primary を使用します。

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Surface(color = MaterialTheme.colorScheme.primary) {
        Text(
            text = "Hello $name!",
            modifier = modifier
        )
    }
}

Surface 内にネストされているコンポーネントは、その背景色の上に描画されます。

プレビューで新しい変更を確認できます。

c88121ec49bde8c7.png

重要な点を見落とさないでください。テキストが白くなっています。これはいつ定義したのでしょうか?

実は、定義していません。androidx.compose.material3.Surface などのマテリアル コンポーネントは、テキストに適切な色を選択するなど、一般的にアプリに望まれる内容を指定することで、ユーザー エクスペリエンスが向上するように設計されています。マテリアルは、大半のアプリに通用する適切なデフォルト値とパターンを提供するものであるため、柔軟性がありません。Compose のマテリアル コンポーネントは、他の基盤コンポーネント(androidx.compose.foundation にあるコンポーネント)の上に構築されており、より柔軟性が必要な場合は、アプリ コンポーネントからもアクセスできます。

今回のケースでは、背景が primary カラーに設定されており、その際には背景の上にあるテキストに onPrimary カラー(これはテーマでも定義されています)を使用すべきであることが、Surface によって認識されています。詳しくは、アプリのテーマを設定するセクションをご覧ください。

修飾子

SurfaceText といったほとんどの Compose UI 要素は、省略可能な modifier パラメータを受け入れます。修飾子は、UI 要素に対して親レイアウト内での配置、表示、動作を指示します。お気づきかもしれませんが、Greeting コンポーザブルにはすでにデフォルトの修飾子があり、これが Text に渡されます。

たとえば、padding 修飾子は、修飾対象の要素の周囲に一定量のスペースを配置します。パディング修飾子は Modifier.padding() で作成できます。複数の修飾子を連鎖させて追加することもできます。この例では、デフォルトの修飾子にパディング修飾子 modifier.padding(24.dp) を追加できます。

それでは、画面上の Text にパディングを追加しましょう。

import androidx.compose.foundation.layout.padding
import androidx.compose.ui.unit.dp
// ...

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Surface(color = MaterialTheme.colorScheme.primary) {
        Text(
            text = "Hello $name!",
            modifier = modifier.padding(24.dp)
        )
    }
}

ef14f7c54ae7edf.png

さまざまな修飾子があり、位置揃え、アニメーション化、配置、変換、クリック可能 / スクロール可能にする処理などに使用できます。包括的なリストについては、Compose 修飾子のリストをご覧ください。次のステップでは、これらの修飾子の一部を使用します。

5. コンポーザブルを再利用する

UI に追加するコンポーネントが多くなると、作成するネストのレベルが増えます。これは、関数が非常に大規模なものになる場合、読みやすさに影響する可能性があります。再利用可能な小さいコンポーネントを作成すると、アプリで使用する UI 要素のライブラリを簡単に構築できます。UI 要素は、それぞれで画面の一部を処理し、個別に編集できます。

ベスト プラクティスとして、デフォルトでは空の修飾子を割り当てた修飾子パラメータを関数に含めることをおすすめします。この修飾子を、関数内で最初に呼び出すコンポーザブルに転送します。こうすると、呼び出し元のサイトがコンポーズ可能な関数の外部でレイアウトの手順と動作を調整できます。

あいさつ文を含む MyApp という名前のコンポーザブルを作成します。

@Composable
fun MyApp(modifier: Modifier = Modifier) {
    Surface(
        modifier = modifier,
        color = MaterialTheme.colorScheme.background
    ) {
        Greeting("Android")
    }
}

これにより、MyApp コンポーザブルを再利用してコードの重複を回避できるようになったので、onCreate コールバックとプレビューをクリーンアップします。

プレビューで、MyApp を呼び出し、プレビューの名前を削除しましょう。

MainActivity.kt ファイルは次のようになります。

package com.example.basicscodelab

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.basicscodelab.ui.theme.BasicsCodelabTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BasicsCodelabTheme {
                MyApp(modifier = Modifier.fillMaxSize())
            }
        }
    }
}

@Composable
fun MyApp(modifier: Modifier = Modifier) {
    Surface(
        modifier = modifier,
        color = MaterialTheme.colorScheme.background
    ) {
        Greeting("Android")
    }
}

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Surface(color = MaterialTheme.colorScheme.primary) {
        Text(
            text = "Hello $name!",
            modifier = modifier.padding(24.dp)
        )
    }
}

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
    BasicsCodelabTheme {
        MyApp()
    }
}

6. 列と行を作成する

Compose には、基本の標準レイアウト要素として、ColumnRowBox という 3 つのコンポーザブルがあります。

518dbfad23ee1b05.png

これらのコンポーザブルは、コンポーズ可能なコンテンツを受け取るコンポーズ可能な関数であり、内部にアイテムを配置できます。たとえば、Column 内のそれぞれの子は縦方向に配置されます。

// Don't copy over
Column {
    Text("First row")
    Text("Second row")
}

では、Greeting を変更して、下記の例のように 2 つのテキスト要素を持つ列が表示されるようにしましょう。

bf27ee688c3231df.png

パディングの移動が必要な場合があるので注意してください。

変更できたら、次の解答コードと比べてみてください。

import androidx.compose.foundation.layout.Column
// ...

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Surface(color = MaterialTheme.colorScheme.primary) {
        Column(modifier = modifier.padding(24.dp)) {
            Text(text = "Hello ")
            Text(text = name)
        }
    }
}

Compose と Kotlin

コンポーズ可能な関数は、Kotlin の他の関数と同様に使用できます。つまり、UI の表示方法に影響を与えるステートメントを追加できるので、UI の作成が非常に容易になります。

たとえば、for ループを使用して Column に要素を追加できます。

@Composable
fun MyApp(
    modifier: Modifier = Modifier,
    names: List<String> = listOf("World", "Compose")
) {
    Column(modifier) {
        for (name in names) {
            Greeting(name = name)
        }
    }
}

a7ba2a8cb7a7d79d.png

ディメンションの設定も、コンポーザブルのサイズに対する制約の追加もまだ行っていないため、各行の占めるスペースは最小限になっています。プレビューにも同じ結果が表示されます。プレビューを変更して、小型のスマートフォンの一般的な幅(320 dp)をエミュレートしましょう。次のように、widthDp パラメータを @Preview アノテーションに追加します。

@Preview(showBackground = true, widthDp = 320)
@Composable
fun GreetingPreview() {
    BasicsCodelabTheme {
        MyApp()
    }
}

a5d5f6cdbdd918a2.png

修飾子は Compose で広く使用されますので、より高度な演習を行いましょう。fillMaxWidth 修飾子と padding 修飾子を使用して、次のレイアウトを再現してください。

a9599061cf49a214.png

作成したコードを次の解答と比較してください。

import androidx.compose.foundation.layout.fillMaxWidth

@Composable
fun MyApp(
    modifier: Modifier = Modifier,
    names: List<String> = listOf("World", "Compose")
) {
    Column(modifier = modifier.padding(vertical = 4.dp)) {
        for (name in names) {
            Greeting(name = name)
        }
    }
}

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Surface(
        color = MaterialTheme.colorScheme.primary,
        modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Column(modifier = Modifier.fillMaxWidth().padding(24.dp)) {
            Text(text = "Hello ")
            Text(text = name)
        }
    }
}

以下の点にご注意ください。

  • 修飾子にはオーバーロードを含めることができるので、パディングを作成する場合などには、別の方法を指定できます。
  • 1 つの要素に複数の修飾子を追加する場合は、修飾子をつなげていけば大丈夫です。

解答方法は複数あるので、作成したコードがこのスニペットと一致しないからといって、必ずしもコードが間違っているわけではありません。ここでは、Codelab を進めるために上記のコードをコピーして貼り付けてください。

ボタンを追加する

次のステップとして、Greeting を展開するクリック可能な要素を追加するために、そのボタンを先に追加する必要があります。目標は次のようなレイアウトを作成することです。

ff2d8c3c1349a891.png

Button は、material3 パッケージによって提供されるコンポーザブルで、最後の引数としてコンポーザブルを受け取ります。後置ラムダはかっこの外側に移動できるため、任意のコンテンツを子としてボタンに追加できます。たとえば、次のように Text を追加できます。

// Don't copy yet
Button(
    onClick = { } // You'll learn about this callback later
) {
    Text("Show less")
}

このような記述を行うには、行の末尾にコンポーザブルを配置するための方法を知る必要があります。alignEnd 修飾子が存在しないため、代わりに先頭のコンポーザブルで weight を指定します。weight 修飾子を使用すると、その要素で利用可能なスペースがすべて使われるようになり、「柔軟性が高く」なります。重みが指定されていない(「柔軟性が低い」)要素は適度に押しのけられます。また、fillMaxWidth 修飾子が不要になります。

では、ボタンを追加して、上記の画像のように配置してみましょう。

以下の解答を確認してください。

import androidx.compose.foundation.layout.Row
import androidx.compose.material3.ElevatedButton
// ...

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Surface(
        color = MaterialTheme.colorScheme.primary,
        modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Row(modifier = Modifier.padding(24.dp)) {
            Column(modifier = Modifier.weight(1f)) {
                Text(text = "Hello ")
                Text(text = name)
            }
            ElevatedButton(
                onClick = { /* TODO */ }
            ) {
                Text("Show more")
            }
        }
    }
}

7. Compose の状態

このセクションでは、画面にインタラクションを追加します。これまでに作成したのは静的レイアウトですが、次はレイアウトがユーザーの変更に反応するようにして、以下の動作を実現します。

6675d41779cac69.gif

ボタンをクリック可能にする方法とアイテムのサイズを変更する方法を学ぶ前に、各アイテムが展開されているかどうかを示す値(つまり、アイテムの状態)をどこかに保存する必要があります。そのような値はあいさつ文ごとに 1 つ必要なので、値を保存する場所は論理的に Greeting コンポーザブル内ということになります。そのためのブール値 expanded とコード内での使用方法を、次のコードで確認してください。

// Don't copy over
@Composable
fun Greeting(name: String) {
    var expanded = false // Don't do this!

    Surface(
        color = MaterialTheme.colorScheme.primary,
        modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Row(modifier = Modifier.padding(24.dp)) {
            Column(modifier = Modifier.weight(1f)) {
                Text(text = "Hello, ")
                Text(text = name)
            }
            ElevatedButton(
                onClick = { expanded = !expanded }
            ) {
                Text(if (expanded) "Show less" else "Show more")
            }
        }
    }
}

onClick アクションと動的ボタンテキストも追加されていることに注意してください。詳しくは後で説明します。

しかし、これは期待どおりに機能しませんexpanded 変数に異なる値が設定されても、Compose はそれを「状態変更」として検出しないため、何も起こりません。

変数の値が変わっても再コンポジションがトリガーされないのは、変更が Compose によってトラッキングされていないためです。また、Greeting が呼び出されるたびに、変数は false にリセットされます。

コンポーザブルに内部状態を追加するには、mutableStateOf 関数を使用します。そうすれば、Compose は State を読み取る関数を再コンポーズするようになります。

import androidx.compose.runtime.mutableStateOf
// ...

// Don't copy over
@Composable
fun Greeting() {
    val expanded = mutableStateOf(false) // Don't do this!
}

しかし、コンポーザブル内の変数に mutableStateOf割り当てることはできません。前述のとおり、再コンポジションが随時発生する可能性があります。その結果、コンポーザブルが再度呼び出されて変更可能な状態がリセットされ、値が新たに false になることが考えられます。

再コンポジションの前後で状態を保持するには、remember を使用して可変状態を「記憶」します。

import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
// ...

@Composable
fun Greeting(...) {
    val expanded = remember { mutableStateOf(false) }
    // ...
}

remember を使用すると、再コンポジションから保護されるため、状態はリセットされません。

なお、同じコンポーザブルを画面の複数の部分から別々に呼び出すと、異なる UI 要素が作成され、状態もそれぞれで別になるので注意してください。内部状態は、クラス内のプライベート変数と見なすことができます。

コンポーズ可能な関数は、状態に対して自動的に「登録」されます。状態が変化すると、それらのフィールドを読み取るコンポーザブルは、更新を表示に反映させるために再コンポーズされます。

状態の変更と状態変更への反応

状態を変更するため、Button には onClick というパラメータがあることにお気づきだと思いますが、この関数は値ではなく関数を受け取ります

クリック時に行うアクションを定義するには、ラムダ式を代入します。たとえば、展開済み状態の値を切り替え、値に応じて異なるテキストを表示します。

ElevatedButton(
    onClick = { expanded.value = !expanded.value },
) {
   Text(if (expanded.value) "Show less" else "Show more")
}

アプリをインタラクティブ モードで実行して動作を確認します。

374998ad358bf8d6.png

ボタンがクリックされると、expanded が切り替わり、ボタンの内側のテキストの再コンポジションがトリガーされます。各 Greeting は異なる UI 要素に属するため、固有の展開済み状態を保持します。

93d839b53b7d9bea.gif

これまでに作成したコード:

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    val expanded = remember { mutableStateOf(false) }
    Surface(
        color = MaterialTheme.colorScheme.primary,
        modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Row(modifier = Modifier.padding(24.dp)) {
            Column(modifier = Modifier.weight(1f)) {
                Text(text = "Hello ")
                Text(text = name)
            }
            ElevatedButton(
                onClick = { expanded.value = !expanded.value }
            ) {
                Text(if (expanded.value) "Show less" else "Show more")
            }
        }
    }
}

アイテムを展開する

それでは、リクエストに応じてアイテムを実際に展開しましょう。状態に依存する変数を追加します。

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {

    val expanded = remember { mutableStateOf(false) }

    val extraPadding = if (expanded.value) 48.dp else 0.dp
// ...

extraPadding は、単純な計算を行うだけであるため、再コンポジションに備えて記憶する必要はありません。

以上で、新しいパディング修飾子を列に適用できるようになりました。

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    val expanded = remember { mutableStateOf(false) }
    val extraPadding = if (expanded.value) 48.dp else 0.dp
    Surface(
        color = MaterialTheme.colorScheme.primary,
        modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Row(modifier = Modifier.padding(24.dp)) {
            Column(
                modifier = Modifier
                    .weight(1f)
                    .padding(bottom = extraPadding)
            ) {
                Text(text = "Hello ")
                Text(text = name)
            }
            ElevatedButton(
                onClick = { expanded.value = !expanded.value }
            ) {
                Text(if (expanded.value) "Show less" else "Show more")
            }
        }
    }
}

エミュレータまたはインタラクティブ モードでアプリを実行すると、各アイテムを個別に展開できることがわかります。

6675d41779cac69.gif

8. 状態ホイスティング

コンポーズ可能な関数では、複数の関数によって読み取られるか変更される状態は、共通の祖先に配置される必要があります。そのためのプロセスを状態ホイスティングと呼びます。「ホイストする」とは、「持ち上げる」「昇格させる」といった意味です。

状態をホイスト可能にすると、状態の重複やバグの発生を回避できます。これにより、コンポーザブルを再利用し、非常に簡単にテストできるようになります。一方、コンポーザブルの親による制御を必要としない状態はホイストするべきではありません。状態を作成および制御するものであれば、それらはすべて信頼できる情報源となります。

例として、アプリのオンボーディング画面を作成しましょう。

5d5f44508fcfa779.png

MainActivity.kt に次のコードを追加します。

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.material3.Button
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
// ...

@Composable
fun OnboardingScreen(modifier: Modifier = Modifier) {
    // TODO: This state should be hoisted
    var shouldShowOnboarding by remember { mutableStateOf(true) }

    Column(
        modifier = modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Welcome to the Basics Codelab!")
        Button(
            modifier = Modifier.padding(vertical = 24.dp),
            onClick = { shouldShowOnboarding = false }
        ) {
            Text("Continue")
        }
    }
}

@Preview(showBackground = true, widthDp = 320, heightDp = 320)
@Composable
fun OnboardingPreview() {
    BasicsCodelabTheme {
        OnboardingScreen()
    }
}

このコードには、複数の新機能が含まれています。

  • OnboardingScreen という新しいコンポーザブルと、新しいプレビューが追加されました。プロジェクトをビルドすると、複数のプレビューを同時に表示できることがわかります。また、コンテンツが正しく配置されることを確認するため、固定の高さも追加されています。
  • Column を設定すると、コンテンツを画面の中央に表示できます。
  • shouldShowOnboarding は、= キーワードではなく by キーワードを使用しています。これは、毎回 .value を入力する手間を省くためのプロパティ デリゲートです。
  • ボタンがクリックされると、shouldShowOnboardingfalse に設定されますが、状態の読み取りはまだどこからも行われていません。

ここで、この新しいオンボーディング画面をアプリに追加できます。起動時にこの画面を表示し、ユーザーが [Continue] を押すと非表示になります。

Compose では、UI 要素を非表示にすることはありません。非表示にするのではなく、単に UI 要素をコンポジションに追加しないようにして、それらの要素が Compose の生成する UI ツリーに追加されないようにします。そのために、シンプルな Kotlin の条件ロジックを使用します。たとえば、オンボーディング画面またはあいさつ文のリストを表示するには、次のようなコードを記述します。

// Don't copy yet
@Composable
fun MyApp(modifier: Modifier = Modifier) {
    Surface(modifier) {
        if (shouldShowOnboarding) { // Where does this come from?
            OnboardingScreen()
        } else {
            Greetings()
        }
    }
}

しかし、shouldShowOnboarding にはアクセスできません。OnboardingScreen で作成した状態を MyApp コンポーザブルと共有する必要があるのです。

状態の値をなんらかの方法で親と共有する代わりに、状態をホイストします。ホイストは、状態にアクセスする必要がある共通の祖先に状態を移動するだけです。

まず、MyApp の内容を Greetings という新しいコンポーザブルに移動します。代わりに Greetings メソッドを呼び出すように、プレビューを調整することもできます。

@Composable
fun MyApp(modifier: Modifier = Modifier) {
     Greetings()
}

@Composable
private fun Greetings(
    modifier: Modifier = Modifier,
    names: List<String> = listOf("World", "Compose")
) {
    Column(modifier = modifier.padding(vertical = 4.dp)) {
        for (name in names) {
            Greeting(name = name)
        }
    }
}

@Preview(showBackground = true, widthDp = 320)
@Composable
fun GreetingsPreview() {
    BasicsCodelabTheme {
        Greetings()
    }
}

新しいトップレベル MyApp コンポーザブルのプレビューを追加し、その動作をテストできるようにします。

@Preview
@Composable
fun MyAppPreview() {
    BasicsCodelabTheme {
        MyApp(Modifier.fillMaxSize())
    }
}

次に、さまざまな画面を表示するロジックを MyApp に追加し、状態をホイストします。

@Composable
fun MyApp(modifier: Modifier = Modifier) {

    var shouldShowOnboarding by remember { mutableStateOf(true) }

    Surface(modifier) {
        if (shouldShowOnboarding) {
            OnboardingScreen(/* TODO */)
        } else {
            Greetings()
        }
    }
}

shouldShowOnboarding をオンボーディング画面と共有する必要もありますが、直接渡すことはしません。OnboardingScreen に状態を変更させるよりも、ユーザーが [Continue] ボタンをクリックしたときに通知させる方が適切です。

イベントを上方向に渡すには、コールバックを下方向に渡します。コールバックは、他の関数に引数として渡され、イベントが発生したときに実行される関数です。

MyApp から状態を変更できるように、onContinueClicked: () -> Unit として定義されているオンボーディング画面に関数パラメータを追加してみましょう。

解答:

@Composable
fun MyApp(modifier: Modifier = Modifier) {

    var shouldShowOnboarding by remember { mutableStateOf(true) }

    Surface(modifier) {
        if (shouldShowOnboarding) {
            OnboardingScreen(onContinueClicked = { shouldShowOnboarding = false })
        } else {
            Greetings()
        }
    }
}

@Composable
fun OnboardingScreen(
    onContinueClicked: () -> Unit,
    modifier: Modifier = Modifier
) {

    Column(
        modifier = modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Welcome to the Basics Codelab!")
        Button(
            modifier = Modifier
                .padding(vertical = 24.dp),
            onClick = onContinueClicked
        ) {
            Text("Continue")
        }
    }

}

状態ではなく関数を OnboardingScreen に渡すことで、このコンポーザブルを再利用しやすくし、他のコンポーザブルによって変更されないように状態を保護します。通常は、これで処理がシンプルになります。オンボーディング プレビューを変更し、OnboardingScreen を直ちに呼び出す必要がある場合の処理方法などが一例です。

@Preview(showBackground = true, widthDp = 320, heightDp = 320)
@Composable
fun OnboardingPreview() {
    BasicsCodelabTheme {
        OnboardingScreen(onContinueClicked = {}) // Do nothing on click.
    }
}

onContinueClicked を空のラムダ式に代入すると「何もしない」という意味になり、プレビューに最適です。

これでアプリがさらに完成に近づきました。

25915eb273a7ef49.gif

MyApp コンポーザブルでは、by というプロパティ委譲を初めて使用して、毎回値を使用することを回避しました。Greeting コンポーザブルでも expanded プロパティに = ではなく by を使用しましょう。expandedval から var に変更してください。

これまでに作成したコードの全文:

package com.example.basicscodelab

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.codelab.basics.ui.theme.BasicsCodelabTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BasicsCodelabTheme {
                MyApp(modifier = Modifier.fillMaxSize())
            }
        }
    }
}

@Composable
fun MyApp(modifier: Modifier = Modifier) {

    var shouldShowOnboarding by remember { mutableStateOf(true) }

    Surface(modifier) {
        if (shouldShowOnboarding) {
            OnboardingScreen(onContinueClicked = { shouldShowOnboarding = false })
        } else {
            Greetings()
        }
    }
}

@Composable
fun OnboardingScreen(
    onContinueClicked: () -> Unit,
    modifier: Modifier = Modifier
) {

    Column(
        modifier = modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Welcome to the Basics Codelab!")
        Button(
            modifier = Modifier.padding(vertical = 24.dp),
            onClick = onContinueClicked
        ) {
            Text("Continue")
        }
    }
}

@Composable
private fun Greetings(
    modifier: Modifier = Modifier,
    names: List<String> = listOf("World", "Compose")
) {
    Column(modifier = modifier.padding(vertical = 4.dp)) {
        for (name in names) {
            Greeting(name = name)
        }
    }
}

@Preview(showBackground = true, widthDp = 320, heightDp = 320)
@Composable
fun OnboardingPreview() {
    BasicsCodelabTheme {
        OnboardingScreen(onContinueClicked = {})
    }
}

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {

    var expanded by remember { mutableStateOf(false) }

    val extraPadding = if (expanded) 48.dp else 0.dp

    Surface(
        color = MaterialTheme.colorScheme.primary,
        modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Row(modifier = Modifier.padding(24.dp)) {
            Column(
                modifier = Modifier
                .weight(1f)
                .padding(bottom = extraPadding)
            ) {
                Text(text = "Hello, ")
                Text(text = name)
            }
            ElevatedButton(
                onClick = { expanded = !expanded }
            ) {
                Text(if (expanded) "Show less" else "Show more")
            }
        }
    }
}

@Preview(showBackground = true, widthDp = 320)
@Composable
fun GreetingPreview() {
    BasicsCodelabTheme {
        Greetings()
    }
}

@Preview
@Composable
fun MyAppPreview() {
    BasicsCodelabTheme {
        MyApp(Modifier.fillMaxSize())
    }
}

9. 効率の良い遅延リストを作成する

次に、名前リストの実用性を高めましょう。これまでは、2 つのあいさつ文を 1 つの Column で表示していました。しかし、この方法で数千のあいさつ文を処理できるでしょうか。

Greetings パラメータのデフォルトのリスト値を変更して、別のリスト コンストラクタを使用します。そうすると、リストサイズを設定して、ラムダに含まれる値でリストを埋めることができます(下記のコードの $it はリスト インデックスを表します)。

names: List<String> = List(1000) { "$it" }

このコードは、画面に収まらないものも含めて、1,000 のあいさつ文を作成します。これは明らかに効率が良くありません。試しにエミュレータで実行してみてください(このコードによりエミュレータがフリーズする可能性がありるので注意してください)。

スクロール可能な列を表示するには、LazyColumn を使用します。LazyColumn は画面に表示されているアイテムのみをレンダリングするので、大きなリストを表示する場合は効率が良くなります。

LazyColumn API の基本的な利用方法では、スコープ内で items 要素を使用します。この要素には、個々のアイテムをレンダリングするロジックを記述します。

import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
// ...

@Composable
private fun Greetings(
    modifier: Modifier = Modifier,
    names: List<String> = List(1000) { "$it" }
) {
    LazyColumn(modifier = modifier.padding(vertical = 4.dp)) {
        items(items = names) { name ->
            Greeting(name = name)
        }
    }
}

284f925eb984fb56.gif

10. 状態を維持する

このアプリには問題が 2 つあります。

オンボーディング画面の状態の維持

デバイスでアプリを実行し、ボタンをクリックしてから回転させると、オンボーディング画面が再度表示されます。remember 関数は、コンポーザブルが Composition 内で保持されている間にのみ機能します。回転させると、アクティビティ全体が再起動され、すべての状態が失われます。この問題は、構成の変更やプロセスの終了の際にも発生します。

remember の代わりに rememberSaveable を使用できます。そうすれば、個々の状態が保存され、構成の変更(回転など)やプロセスの終了後も保持されます。

shouldShowOnboarding 内で使用されている rememberrememberSaveable に置き換えます。

    import androidx.compose.runtime.saveable.rememberSaveable
    // ...

    var shouldShowOnboarding by rememberSaveable { mutableStateOf(true) }

アプリを実行して、回転させたり、ダークモードに変更したり、プロセスを強制終了したりします。前にアプリを終了した場合を除いて、オンボーディング画面は表示されません。

リストアイテムの展開済み状態の維持

リストアイテムを展開してから、そのアイテムがビューから外れるまでリストをスクロールするか、デバイスを回転してから、展開済みのアイテムに戻ると、アイテムが初期状態に戻ります。

この問題を解決するには、展開済みの状態にも rememberSaveable を使用します。

   var expanded by rememberSaveable { mutableStateOf(false) }

これまでに作成した 120 行ほどのコードにより、効率の良い長いスクロール リストが表示され、各アイテムが固有の状態を保持するようになりました。また、ご覧のとおり、追加のコードなしでアプリは完全に適切なダークモードになっています。テーマ設定については後で説明します。

11. リストをアニメーション化する

Compose には、シンプルなアニメーションのための高レベル API から、完全なコントロールと複雑な遷移のための低レベルのメソッドまで、UI をアニメーション化するさまざまな方法があります。詳しくは、こちらのドキュメントをご覧ください。

このセクションでは低レベル API の 1 つを使用しますが、心配は要りません。使い方は非常にシンプルです。すでに実装されているサイズの変更をアニメーション化しましょう。

9efa14ce118d3835.gif

そのためには、animateDpAsState コンポーザブルを使用します。この関数は State オブジェクトを返しますが、このオブジェクトの value はアニメーションが終了するまで継続的に更新されます。また、型が Dp の「ターゲット値」を受け取ります。

アニメーション化された extraPadding を作成します。これは展開済みの状態によって変わります。

import androidx.compose.animation.core.animateDpAsState

@Composable
private fun Greeting(name: String, modifier: Modifier = Modifier) {

    var expanded by rememberSaveable { mutableStateOf(false) }

    val extraPadding by animateDpAsState(
        if (expanded) 48.dp else 0.dp
    )
    Surface(
        color = MaterialTheme.colorScheme.primary,
        modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Row(modifier = Modifier.padding(24.dp)) {
            Column(modifier = Modifier
                .weight(1f)
                .padding(bottom = extraPadding)
            ) {
                Text(text = "Hello, ")
                Text(text = name)
            }
            ElevatedButton(
                onClick = { expanded = !expanded }
            ) {
                Text(if (expanded) "Show less" else "Show more")
            }

        }
    }
}

アプリを実行して、アニメーションを確認してみましょう。

animateDpAsState は、アニメーションをカスタマイズするための animationSpec パラメータ(省略可能)を受け取ります。バネのような愉快なアニメーションを追加しましょう。

import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring

@Composable
private fun Greeting(name: String, modifier: Modifier = Modifier) {

    var expanded by rememberSaveable { mutableStateOf(false) }

    val extraPadding by animateDpAsState(
        if (expanded) 48.dp else 0.dp,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness = Spring.StiffnessLow
        )
    )

    Surface(
    // ...
            Column(modifier = Modifier
                .weight(1f)
                .padding(bottom = extraPadding.coerceAtLeast(0.dp))

    // ...

    )
}

また、パディングが決して負数にならないようにします。そうしないと、アプリがクラッシュする可能性があります。これはアニメーションの軽微なバグです。このバグは、後ほど最終仕上げで修正します。

spring の仕様では、時間に関連するパラメータは受け取りません。その代わりに、物理的な特性(減衰と剛性)に基づいて、より自然なアニメーションを実現します。アプリを実行して、新しいアニメーションを試してみましょう。

9efa14ce118d3835.gif

animate*AsState で作成されたアニメーションは、すべて割り込み可能です。つまり、アニメーションの途中でターゲット値が変更されると、animate*AsState はアニメーションを再実行し、新しい値をポイントします。割り込みは、バネを基にしたアニメーションでは特に自然に見えます。

d5dbf92de69db775.gif

さまざまなタイプのアニメーションを確認したい場合は、spring のパラメータ、仕様(tweenrepeatable)、関数(animateColorAsState)、アニメーション APIなどをそれぞれ変えて試してみてください。

このセクションのコードの全文

package com.example.basicscodelab

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.codelab.basics.ui.theme.BasicsCodelabTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BasicsCodelabTheme {
                MyApp(modifier = Modifier.fillMaxSize())
            }
        }
    }
}

@Composable
fun MyApp(modifier: Modifier = Modifier) {

    var shouldShowOnboarding by rememberSaveable { mutableStateOf(true) }

    Surface(modifier) {
        if (shouldShowOnboarding) {
            OnboardingScreen(onContinueClicked = { shouldShowOnboarding = false })
        } else {
            Greetings()
        }
    }
}

@Composable
fun OnboardingScreen(
    onContinueClicked: () -> Unit,
    modifier: Modifier = Modifier
) {

    Column(
        modifier = modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Welcome to the Basics Codelab!")
        Button(
            modifier = Modifier.padding(vertical = 24.dp),
            onClick = onContinueClicked
        ) {
            Text("Continue")
        }
    }

}

@Composable
private fun Greetings(
    modifier: Modifier = Modifier,
    names: List<String> = List(1000) { "$it" }
) {
    LazyColumn(modifier = modifier.padding(vertical = 4.dp)) {
        items(items = names) { name ->
            Greeting(name = name)
        }
    }
}

@Preview(showBackground = true, widthDp = 320, heightDp = 320)
@Composable
fun OnboardingPreview() {
    BasicsCodelabTheme {
        OnboardingScreen(onContinueClicked = {})
    }
}

@Composable
private fun Greeting(name: String, modifier: Modifier = Modifier) {

    var expanded by rememberSaveable { mutableStateOf(false) }

    val extraPadding by animateDpAsState(
        if (expanded) 48.dp else 0.dp,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness = Spring.StiffnessLow
        )
    )
    Surface(
        color = MaterialTheme.colorScheme.primary,
        modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Row(modifier = Modifier.padding(24.dp)) {
            Column(modifier = Modifier
                .weight(1f)
                .padding(bottom = extraPadding.coerceAtLeast(0.dp))
            ) {
                Text(text = "Hello, ")
                Text(text = name)
            }
            ElevatedButton(
                onClick = { expanded = !expanded }
            ) {
                Text(if (expanded) "Show less" else "Show more")
            }
        }
    }
}

@Preview(showBackground = true, widthDp = 320)
@Composable
fun GreetingPreview() {
    BasicsCodelabTheme {
        Greetings()
    }
}

@Preview
@Composable
fun MyAppPreview() {
    BasicsCodelabTheme {
        MyApp(Modifier.fillMaxSize())
    }
}

12. アプリのスタイルとテーマを設定する

これまでにコンポーザブルのスタイルは設定していませんが、ダークモードのサポートなどの適切なデフォルト値はすでに設定されています。BasicsCodelabThemeMaterialTheme について詳しく見てみましょう。

ui/theme/Theme.kt ファイルを開くと、BasicsCodelabTheme の実装で MaterialTheme が使用されていることがわかります。

// Do not copy
@Composable
fun BasicsCodelabTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    // Dynamic color is available on Android 12+
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit
) {
    // ...

    MaterialTheme(
        colorScheme = colorScheme,
        typography = Typography,
        content = content
    )
}

MaterialTheme は、マテリアル デザイン仕様のスタイル設定の原則を反映したコンポーズ可能な関数です。このスタイル設定情報は、content 内のコンポーネントにカスケードされて適用されます。各コンポーネントは情報を読み取って自身のスタイルを設定できます。この例の UI では、次のように BasicsCodelabTheme がすでに使用されています。

    BasicsCodelabTheme {
        MyApp(modifier = Modifier.fillMaxSize())
    }

BasicsCodelabThemeMaterialTheme を内部的にラップするので、MyApp はテーマで定義されているプロパティでスタイル設定されます。任意の子孫コンポーザブルから、MaterialTheme の 3 つのプロパティ(colorSchemetypographyshapes)を取得できます。これらを使用して、Text の 1 つのヘッダー スタイルを設定します。

            Column(modifier = Modifier
                .weight(1f)
                .padding(bottom = extraPadding.coerceAtLeast(0.dp))
            ) {
                Text(text = "Hello, ")
                Text(text = name, style = MaterialTheme.typography.headlineMedium)
            }

上記の例の Text コンポーザブルは、新しい TextStyle を設定しています。独自の TextStyle を作成することも、推奨される MaterialTheme.typography を使用して、テーマで定義されているスタイルを取得することもできます。この構造体を使用すると、マテリアルで定義されているテキスト スタイル(displayLarge, headlineMedium, titleSmall, bodyLarge, labelMedium など)にアクセスできます。この例では、テーマで定義されている headlineMedium スタイルを使用します。

ビルドして、新しいスタイルが設定されたテキストを確認します。

673955c38b076f1c.png

一般的に、色、シェイプ、フォント スタイルは MaterialTheme 内で保持することをおすすめします。たとえば、色をハードコードするとダークモードの実装が困難になり、修正が必要な間違いやすい作業が多数発生する可能性があります。

ただし、選択済みの色やフォント スタイルとわずかに異なるものを使用したい場合があります。そのような場合は、既存の色やスタイルを基にして設定することをおすすめします。

この場合、copy 関数を使用すると、定義済みのスタイルを変更できます。番号を極太にします。

import androidx.compose.ui.text.font.FontWeight
// ...
Text(
    text = name,
    style = MaterialTheme.typography.headlineMedium.copy(
        fontWeight = FontWeight.ExtraBold
    )
)

このようにすれば、headlineMedium のフォント ファミリーやその他の属性を変更する必要がある場合でも、わずかな例外について心配せずに済みます。

プレビュー ウィンドウの表示は次のようになります。

b33493882bda9419.png

ダークモードのプレビューをセットアップする

現時点では、プレビューにはライトモードでのアプリの外観のみが表示されます。@Preview アノテーションで UI_MODE_NIGHT_YES を指定して、GreetingPreview に追加します。

import android.content.res.Configuration.UI_MODE_NIGHT_YES

@Preview(
    showBackground = true,
    widthDp = 320,
    uiMode = UI_MODE_NIGHT_YES,
    name = "GreetingPreviewDark"
)
@Preview(showBackground = true, widthDp = 320)
@Composable
fun GreetingPreview() {
    BasicsCodelabTheme {
        Greetings()
    }
}

これで、ダークモードのプレビューが追加されました。

2c94dc7775d80166.png

アプリのテーマを微調整する

現在のテーマに関連するものは、すべて ui/theme フォルダ内のファイルで確認できます。たとえば、これまでに使用したデフォルトの色は Color.kt で定義されています。

まず、新しい色を定義しましょう。Color.kt に以下の行を追加します。

val Navy = Color(0xFF073042)
val Blue = Color(0xFF4285F4)
val LightBlue = Color(0xFFD7EFFE)
val Chartreuse = Color(0xFFEFF7CF)

次に、これらの色を Theme.kt 内にある MaterialTheme のパレットに代入します。

private val LightColorScheme = lightColorScheme(
    surface = Blue,
    onSurface = Color.White,
    primary = LightBlue,
    onPrimary = Navy
)

MainActivity.kt に戻ってプレビューを更新しても、プレビューの色は実際には変わりません。デフォルトではプレビューでダイナミック カラーが使用されるためです。dynamicColor ブール値パラメータを使用して、ダイナミック カラーを追加するロジックを Theme.kt で確認できます。

カラーパターンの非アダプティブ バージョンを確認するには、API レベル 31 未満(アダプティブ カラーが導入されていた Android S に対応)のデバイスでアプリを実行します。新しい色が表示されます。

493d754584574e91.png

Theme.kt で、ダークカラーのパレットを定義します。

private val DarkColorScheme = darkColorScheme(
    surface = Blue,
    onSurface = Navy,
    primary = Navy,
    onPrimary = Chartreuse
)

ここでアプリを実行すると、ダークカラーの動作が表示されます。

84d2a903ffa6d8df.png

Theme.kt の最終的なコード

import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.ViewCompat

private val DarkColorScheme = darkColorScheme(
    surface = Blue,
    onSurface = Navy,
    primary = Navy,
    onPrimary = Chartreuse
)

private val LightColorScheme = lightColorScheme(
    surface = Blue,
    onSurface = Color.White,
    primary = LightBlue,
    onPrimary = Navy
)

@Composable
fun BasicsCodelabTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    // Dynamic color is available on Android 12+
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit
) {
    val colorScheme = when {
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            val context = LocalContext.current
            if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
        }
        darkTheme -> DarkColorScheme
        else -> LightColorScheme
    }
    val view = LocalView.current
    if (!view.isInEditMode) {
        SideEffect {
            (view.context as Activity).window.statusBarColor = colorScheme.primary.toArgb()
            ViewCompat.getWindowInsetsController(view)?.isAppearanceLightStatusBars = darkTheme
        }
    }

    MaterialTheme(
        colorScheme = colorScheme,
        typography = Typography,
        content = content
    )
}

13. 最終仕上げ

このステップでは、これまでに学んだことを適用し、少しのヒントを参考にして新しいコンセプトを習得します。最終的には次のようなアプリを作成します。

8d24a786bfe1a8f2.gif

ボタンをアイコンで置き換える

  • IconButton コンポーザブルを子 Icon と一緒に使用します。
  • Icons.Filled.ExpandLessIcons.Filled.ExpandMore を使用します。これらは material-icons-extended アーティファクトから取得できます。app/build.gradle.kts ファイルの依存関係に次の行を追加します。
implementation("androidx.compose.material:material-icons-extended")
  • パディングを変更して配置を修正します。
  • ユーザー補助のためにコンテンツ説明を追加します(下記の「文字列リソースを使用する」をご覧ください)。

文字列リソースを使用する

[Show more] と [Show less] にはコンテンツ説明が必要です。シンプルな if ステートメントを使用してそれらを追加できます。

contentDescription = if (expanded) "Show less" else "Show more"

ただし、文字列のハードコードはおすすめしません。文字列は strings.xml ファイルから取得してください。

各文字列について「文字列リソースの抽出」を使用できます。これは、Android Studio のコンテキスト アクションを使用すると自動的に行えます。

または、app/src/res/values/strings.xml を開いて次のリソースを追加します。

<string name="show_less">Show less</string>
<string name="show_more">Show more</string>

詳細を表示する

「Composem ipsum」のテキストの表示と非表示が切り替わると、各カードのサイズの変更がトリガーされます。

  • アイテムが展開されたときに表示される Greeting 内の列に新しい Text を追加します。
  • extraPadding を削除し、代わりに animateContentSize 修飾子を Row に適用します。これは、手動で行うのは困難なアニメーション作成プロセスを自動化するためです。また、これにより coerceAtLeast も不要になります。

高度とシェイプを追加する

  • shadow 修飾子を clip 修飾子と一緒に使用すると、カードの外観の最終仕上げができます。ただし、このような目的に使用できるマテリアル コンポーザブルとして、Card もあります。Card の色を変更するには、CardDefaults.cardColors を呼び出して、変更する色をオーバーライドします。

最終的なコード

package com.example.basicscodelab

import android.content.res.Configuration.UI_MODE_NIGHT_YES
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons.Filled
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.basicscodelab.ui.theme.BasicsCodelabTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BasicsCodelabTheme {
                MyApp(modifier = Modifier.fillMaxSize())
            }
        }
    }
}

@Composable
fun MyApp(modifier: Modifier = Modifier) {
    var shouldShowOnboarding by rememberSaveable { mutableStateOf(true) }

    Surface(modifier, color = MaterialTheme.colorScheme.background) {
        if (shouldShowOnboarding) {
            OnboardingScreen(onContinueClicked = { shouldShowOnboarding = false })
        } else {
            Greetings()
        }
    }
}

@Composable
fun OnboardingScreen(
    onContinueClicked: () -> Unit,
    modifier: Modifier = Modifier
) {
    Column(
        modifier = modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Welcome to the Basics Codelab!")
        Button(
            modifier = Modifier.padding(vertical = 24.dp),
            onClick = onContinueClicked
        ) {
            Text("Continue")
        }
    }
}

@Composable
private fun Greetings(
    modifier: Modifier = Modifier,
    names: List<String> = List(1000) { "$it" }
) {
    LazyColumn(modifier = modifier.padding(vertical = 4.dp)) {
        items(items = names) { name ->
            Greeting(name = name)
        }
    }
}

@Composable
private fun Greeting(name: String, modifier: Modifier = Modifier) {
    Card(
        colors = CardDefaults.cardColors(
            containerColor = MaterialTheme.colorScheme.primary
        ),
        modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        CardContent(name)
    }
}

@Composable
private fun CardContent(name: String) {
    var expanded by rememberSaveable { mutableStateOf(false) }

    Row(
        modifier = Modifier
            .padding(12.dp)
            .animateContentSize(
                animationSpec = spring(
                    dampingRatio = Spring.DampingRatioMediumBouncy,
                    stiffness = Spring.StiffnessLow
                )
            )
    ) {
        Column(
            modifier = Modifier
                .weight(1f)
                .padding(12.dp)
        ) {
            Text(text = "Hello, ")
            Text(
                text = name, style = MaterialTheme.typography.headlineMedium.copy(
                    fontWeight = FontWeight.ExtraBold
                )
            )
            if (expanded) {
                Text(
                    text = ("Composem ipsum color sit lazy, " +
                        "padding theme elit, sed do bouncy. ").repeat(4),
                )
            }
        }
        IconButton(onClick = { expanded = !expanded }) {
            Icon(
                imageVector = if (expanded) Filled.ExpandLess else Filled.ExpandMore,
                contentDescription = if (expanded) {
                    stringResource(R.string.show_less)
                } else {
                    stringResource(R.string.show_more)
                }
            )
        }
    }
}

@Preview(
    showBackground = true,
    widthDp = 320,
    uiMode = UI_MODE_NIGHT_YES,
    name = "GreetingPreviewDark"
)
@Preview(showBackground = true, widthDp = 320)
@Composable
fun GreetingPreview() {
    BasicsCodelabTheme {
        Greetings()
    }
}

@Preview(showBackground = true, widthDp = 320, heightDp = 320)
@Composable
fun OnboardingPreview() {
    BasicsCodelabTheme {
        OnboardingScreen(onContinueClicked = {})
    }
}

@Preview
@Composable
fun MyAppPreview() {
    BasicsCodelabTheme {
        MyApp(Modifier.fillMaxSize())
    }
}

14. 完了

お疲れさまでした。以上で Compose の基本を習得しました。

Codelab の解答

この Codelab の解答コードは GitHub から入手できます。

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

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

次のステップ

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

参考資料