Compose で画面間を移動する

1.始める前に

ここまで取り組んできたアプリは、1 つの画面で構成されていました。しかし、普段使用している多くのアプリは画面が複数あり、画面間を移動できるようになっているはずです。たとえば設定アプリには、複数の画面にまたがるコンテンツ ページが多数あります。

最新の Android 開発では、マルチスクリーン アプリの作成に Jetpack Navigation コンポーネントを使用します。Navigation Compose コンポーネントを使用すると、ユーザー インターフェースを作成する場合と同様に、宣言型のアプローチによって Compose でマルチスクリーン アプリを簡単に作成できます。この Codelab では、複雑化するアプリにおけるおすすめの方法を示しつつ、Navigation Compose コンポーネントの基本、AppBar をレスポンシブにする方法、インテントを使用してアプリ間でデータを送信する方法を紹介します。

前提条件

  • 関数型、ラムダ、スコープ関数など、Kotlin 言語に精通していること
  • Compose の基本的な Row レイアウトと Column レイアウトに精通していること

学習内容

  • NavHost コンポーザブルを作成して、アプリのルートと画面を定義する。
  • NavHostController を使用して画面間を移動する。
  • バックスタックを操作して前の画面に移動する。
  • インテントを使用して別のアプリとデータを共有する。
  • AppBar をカスタマイズする(タイトルや「戻る」ボタンなど)。

作成するアプリの概要

  • マルチスクリーン アプリのナビゲーションを実装します。

必要なもの

  • Android Studio の最新バージョン
  • スターター コードをダウンロードするためのインターネット接続

2. スターター コードをダウンロードする

まず、スターター コードをダウンロードします。

または、GitHub リポジトリのクローンを作成してコードを入手することもできます。

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-cupcake.git
$ cd basic-android-kotlin-compose-training-cupcake
$ git checkout starter

この Codelab のスターター コードを確認する場合は、GitHub で表示します。

3. アプリの内容

Cupcake アプリは、これまで取り組んできたアプリとは少し異なります。すべてのコンテンツが 1 つの画面に表示されるのではなく、個別の画面が 4 つあり、ユーザーは各画面を移動しながらカップケーキを注文します。アプリを実行しても何も起こらず、ナビゲーション コンポーネントがまだアプリコードに追加されていないため、これらの画面間を移動できません。ただし、各画面のコンポーザブルのプレビューを確認し、以下に示す最終的なアプリの画面と照らし合わせることはできます。

注文開始画面

最初の画面には、注文するカップケーキの数量に対応した 3 つのボタンが表示されます。

コードでは、これは StartOrderScreen.kt の StartOrderScreen コンポーザブルで表現します。

画面は、画像とテキストを含む 1 つの列と、異なる量のカップケーキを注文するための 3 つのカスタムボタンで構成されています。カスタムボタンは、StartOrderScreen.kt の SelectQuantityButton コンポーザブルで実装します。

フレーバー選択画面

数量を選択すると、カップケーキのフレーバーを選択するよう促されます。このアプリでは、ラジオボタンと呼ばれるものを使用して、さまざまなオプションを表示します。ユーザーは複数のフレーバーの中から 1 つを選択できます。

フレーバーのリストは、文字列リソース ID のリストとして data.DataSource.kt に格納されます。

受け取り日選択画面

フレーバーを選択すると、受け取り日を選択する別のラジオボタン群が表示されます。受け取りオプションは、OrderViewModel の pickupOptions() 関数が返すリストに由来します。

フレーバー選択画面と受け取り日選択画面は、同じ SelectOptionScreen.kt の SelectOptionScreen コンポーザブルで表現します。なぜ同じコンポーザブルを使用するのかというと、これらの画面のレイアウトがまったく同じだからです。唯一、データが異なりますが、同じコンポーザブルを使用して、フレーバー画面と受け取り日画面の両方を表示できます。

注文概要画面

受け取り日を選択すると、アプリに注文概要画面が表示され、ユーザーは注文を確認して完了できます。

この画面は、SummaryScreen.kt の OrderSummaryScreen コンポーザブルで実装します。

このレイアウトは、注文に関するすべての情報を含む Column、小計のための Text コンポーザブル、注文を別のアプリに送信するボタン、注文をキャンセルして最初の画面に戻るボタンで構成されています。

ユーザーが別のアプリに注文を送信することを選択した場合、Cupcake アプリは、さまざまな共有オプションを示す Android ShareSheet を表示します。

13bde33712e135a4.png

アプリの現在の状態は data.OrderUiState.kt に格納されます。OrderUiState データクラスには、各画面でのユーザーの選択内容を保存するプロパティが含まれています。

アプリの画面は CupcakeApp コンポーザブルで表示されます。しかしスターター プロジェクトでは、アプリは単に最初の画面を表示するだけです。今のところアプリのすべての画面を移動することはできませんが、ご安心ください。今回はこの点に取り組みます。ナビゲーション ルートの定義、画面間を移動するための NavHost コンポーザブルのセットアップ(デスティネーションともいいます)、共有画面などのシステム UI コンポーネントと統合するインテントの実行、ナビゲーションの変更に対する AppBar の反応について学習します。

再利用可能なコンポーザブル

このコースのサンプルアプリは、おすすめの方法を適宜実践できるように設計されています。Cupcake アプリも例外ではありません。ui.components パッケージには CommonUi.kt というファイルがあり、FormattedPriceLabel コンポーザブルが含まれています。アプリ内の複数の画面でこのコンポーザブルを使用して、注文価格の書式を統一しています。同じ書式と修飾子で同じ Text コンポーザブルを複製するのではなく、FormattedPriceLabel を一度定義しておけば、他の画面で必要な回数だけ再利用できます。

フレーバーの画面と受け取り日の画面は SelectOptionScreen コンポーザブルを使用しており、これも再利用できます。このコンポーザブルは、表示するオプションを表す List<String> 型の options というパラメータを受け取ります。オプションは Row で表示され、RadioButton コンポーザブルと、各文字列を含む Text コンポーザブルで構成されます。Column はレイアウト全体を囲みます。また、書式設定された価格を表示する Text コンポーザブル、[Cancel] ボタン、[Next] ボタンが含まれます。

4. ルートを定義して NavHostController を作成する

Navigation コンポーネントの各部

Navigation コンポーネントは主に 3 つの部分から構成されています。

  • NavController: デスティネーション(アプリの画面)間の移動を担います。
  • NavGraph: コンポーザブルのデスティネーションをマッピングして移動できるようにします。
  • NavHost: NavGraph の現在のデスティネーションを表示するコンテナとして機能するコンポーザブル。

この Codelab では、NavController と NavHost に焦点を当てます。NavHost 内で、Cupcake アプリの NavGraph のデスティネーションを定義します。

アプリ内のデスティネーションのルートを定義する

Compose アプリにおけるナビゲーションの基本コンセプトとして、ルートがあります。ルートとは、デスティネーションに対応する文字列のことで、考え方は URL のコンセプトに似ています。異なる URL がウェブサイトの異なるページにマッピングされるように、ルートはデスティネーションにマッピングされる文字列であり、固有識別子として機能します。デスティネーションは通常、表示内容に対応する単一のコンポーザブル、またはコンポーザブルのグループです。Cupcake アプリでは、注文開始画面、フレーバー画面、受け取り日画面、注文概要画面のデスティネーションが必要です。

アプリの画面数には限りがあるため、ルートの数にも限りがあります。アプリのルートは列挙型クラスを使用して定義できます。Kotlin の列挙型クラスには、プロパティ名を含む文字列を返す name プロパティがあります。

まず、Cupcake アプリの 4 つのルートを定義します。

  • Start: カップケーキの数量を 3 つのボタンのいずれかで選択します。
  • Flavor: フレーバーを選択肢のリストから選択します。
  • Pickup: 受け取り日を選択肢のリストから選択します。
  • Summary: 選択内容を確認し、注文を送信またはキャンセルします。

列挙型クラスを追加してルートを定義します。

  1. CupcakeScreen.kt の CupcakeAppBar コンポーザブルの上に、CupcakeScreen という列挙型クラスを追加します。
enum class CupcakeScreen() {
    
}
  1. この列挙型クラスに、Start、Flavor、Pickup、Summary という 4 つのケースを追加します。
enum class CupcakeScreen() {
    Start,
    Flavor,
    Pickup,
    Summary
}

アプリに NavHost を追加する

NavHost は、所定のルートに基づいて他のコンポーザブルのデスティネーションを表示するコンポーザブルです。たとえばルートが Flavor であれば、NavHost はカップケーキのフレーバーを選択する画面を表示します。ルートが Summary であれば、アプリは概要画面を表示します。

NavHost の構文は、他のコンポーザブルの場合と同じです。

fae7688d6dd53de9.png

注目すべきパラメータは 2 つあります。

  • navController: NavHostController クラスのインスタンス。このオブジェクトを使用して画面間を移動できます(navigate() メソッドを呼び出して別のデスティネーションに移動するなど)。NavHostController は、コンポーズ可能な関数から rememberNavController() を呼び出すことで取得できます。
  • startDestination: アプリが最初に NavHost を表示したときにデフォルトで表示されるデスティネーションを定義する文字列ルート。Cupcake アプリの場合、これは Start ルートになります。

他のコンポーザブルと同様に、NavHost も modifier パラメータを受け取ります。

CupcakeScreen.kt の CupcakeApp コンポーザブルに NavHost を追加します。まず、ナビゲーション コントローラへの参照が必要です。ナビゲーション コントローラは、これから追加する NavHost と後のステップで追加する AppBar の両方で使用できます。そのため、変数を CupcakeApp() コンポーザブルで宣言する必要があります。

  1. CupcakeScreen.kt を開きます。
  2. Scaffold の uiState 変数の下に NavHost コンポーザブルを追加します。
import androidx.navigation.compose.NavHost

Scaffold(
    ...
) { innerPadding ->
    val uiState by viewModel.uiState.collectAsState()

    NavHost()
}
  1. navController パラメータに navController 変数を渡し、startDestination パラメータに CupcakeScreen.Start.name を渡します。CupcakeApp() に渡された修飾子を修飾子パラメータに渡します。最後のパラメータに空の後置ラムダを渡します。
import androidx.compose.foundation.layout.padding

NavHost(
    navController = navController,
    startDestination = CupcakeScreen.Start.name,
    modifier = Modifier.padding(innerPadding)
) {

}

NavHost でルートを処理する

他のコンポーザブルと同様に、NavHost のコンテンツは関数型です。

f67974b7fb3f0377.png

NavHost のコンテンツ関数内で、composable() 関数を呼び出します。composable() 関数には必須のパラメータが 2 つあります。

  • route: ルートの名前に対応する文字列。一意の文字列を指定できます。CupcakeScreen 列挙型の定数の name プロパティを使用します。
  • content: ここで、所定のルートに対して表示するコンポーザブルを呼び出すことができます。

4 つのルートそれぞれについて、composable() 関数を 1 回ずつ呼び出します。

  1. composable() 関数を呼び出し、route に CupcakeScreen.Start.name を渡します。
import androidx.navigation.compose.composable

NavHost(
    navController = navController,
    startDestination = CupcakeScreen.Start.name,
    modifier = Modifier.padding(innerPadding)
) {
    composable(route = CupcakeScreen.Start.name) {
        
    }
}
  1. 後置ラムダ内で、StartOrderScreen コンポーザブルを呼び出し、quantityOptions プロパティに quantityOptions を渡します。modifier には Modifier.fillMaxSize().padding(dimensionResource(R.dimen.padding_medium)) を渡します。
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.res.dimensionResource
import com.example.cupcake.ui.StartOrderScreen
import com.example.cupcake.data.DataSource

NavHost(
    navController = navController,
    startDestination = CupcakeScreen.Start.name,
    modifier = Modifier.padding(innerPadding)
) {
    composable(route = CupcakeScreen.Start.name) {
        StartOrderScreen(
            quantityOptions = DataSource.quantityOptions,
            modifier = Modifier
                .fillMaxSize()
                .padding(dimensionResource(R.dimen.padding_medium))
        )
    }
}
  1. 最初の composable() の呼び出しの下で、composable() を再度呼び出し、route に CupcakeScreen.Flavor.name を渡します。
composable(route = CupcakeScreen.Flavor.name) {
    
}
  1. 後置ラムダ内で、LocalContext.current への参照を取得し、context という変数に格納します。Context は Android システムによって実装が提供される抽象クラスです。アプリケーション固有のリソースとクラスだけでなく、アプリケーション レベルのオペレーション(例: アクティビティの起動)のアップコールへのアクセスを可能にします。この変数を使用して、ビューモデルのリソース ID のリストから文字列を取得しフレーバーのリストを表示できます。
import androidx.compose.ui.platform.LocalContext

composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
}
  1. SelectOptionScreen コンポーザブルを呼び出します。
composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
    SelectOptionScreen(

    )
}
  1. ユーザーがフレーバーを選択したとき、フレーバー画面が小計を表示、更新する必要があります。subtotal パラメータに uiState.price を渡します。
composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
    SelectOptionScreen(
        subtotal = uiState.price
    )
}
  1. フレーバー画面では、アプリの文字列リソースからフレーバーのリストを取得します。map() 関数を使用し、それぞれのフレーバーに対して context.resources.getString(id) を呼び出すことで、リソース ID のリストを文字列のリストに変換します。
import com.example.cupcake.ui.SelectOptionScreen

composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
    SelectOptionScreen(
        subtotal = uiState.price,
        options = DataSource.flavors.map { id -> context.resources.getString(id) }
    )
}
  1. onSelectionChanged パラメータには、ビューモデルの setFlavor() を呼び出すラムダ式を渡し、it(onSelectionChanged() に渡された引数)を渡します。modifier パラメータには Modifier.fillMaxHeight(). を渡します。
import androidx.compose.foundation.layout.fillMaxHeight
import com.example.cupcake.data.DataSource.flavors

composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
    SelectOptionScreen(
        subtotal = uiState.price,
        options = DataSource.flavors.map { id -> context.resources.getString(id) },
        onSelectionChanged = { viewModel.setFlavor(it) },
        modifier = Modifier.fillMaxHeight()
    )
}

受け取り日画面はフレーバー画面に似ています。唯一の違いは、SelectOptionScreen コンポーザブルに渡されるデータです。

  1. composable() 関数を再度呼び出し、route パラメータに CupcakeScreen.Pickup.name を渡します。
composable(route = CupcakeScreen.Pickup.name) {
    
}
  1. 後置ラムダで、SelectOptionScreen コンポーザブルを呼び出し、前と同じように subtotal に uiState.price を渡します。options パラメータには uiState.pickupOptions を渡し、onSelectionChanged パラメータには viewModel の setDate() を呼び出すラムダ式を渡します。modifier パラメータには Modifier.fillMaxHeight(). を渡します。
SelectOptionScreen(
    subtotal = uiState.price,
    options = uiState.pickupOptions,
    onSelectionChanged = { viewModel.setDate(it) },
    modifier = Modifier.fillMaxHeight()
)
  1. composable() をもう一度呼び出し、route に CupcakeScreen.Summary.name を渡します。
composable(route = CupcakeScreen.Summary.name) {
    
}
  1. 後置ラムダで、OrderSummaryScreen() コンポーザブルを呼び出し、orderUiState パラメータに uiState 変数を渡しますmodifier パラメータには Modifier.fillMaxHeight(). を渡します。
import com.example.cupcake.ui.OrderSummaryScreen

composable(route = CupcakeScreen.Summary.name) {
    OrderSummaryScreen(
        orderUiState = uiState,
        modifier = Modifier.fillMaxHeight()
    )
}

NavHost のセットアップは以上です。次のセクションでは、アプリがルートを変更して、ユーザーが各ボタンをタップしたときに画面間を移動するようにします。

5. ルート間を移動する

ルートを定義し、NavHost でコンポーザブルにマッピングしたところで、今度は画面間を移動しましょう。NavHostController(rememberNavController() を呼び出す際の navController プロパティ)は、ルート間の移動を担います。ただし、このプロパティは CupcakeApp コンポーザブルで定義されています。アプリのさまざまな画面からアクセスする方法が必要です。

単に、navController をパラメータとして各コンポーザブルに渡すだけです。

このアプローチは機能しますが、アプリの設計に理想的な方法ではありません。NavHost を使用してアプリのナビゲーションを処理するメリットは、ナビゲーション ロジックが個々の UI とは別に維持されることです。このオプションでは、navController をパラメータとして渡す場合の大きな欠点を回避できます。

  • ナビゲーション ロジックが 1 か所にまとまるため、コードをメンテナンスしやすくなり、誤って個々の画面からアプリ内を自由に移動できるようにしないことで、バグを防止できます。
  • さまざまなフォーム ファクタ(縦向きのスマートフォン、折りたたみ式スマートフォン、大画面のタブレットなど)で動作する必要があるアプリでは、アプリのレイアウトに応じて、ボタンがナビゲーションをトリガーする場合もあれば、しない場合もあります。個々の画面は自己完結している必要があります。アプリ内の他の画面を認識する必要はありません。

代わりに、ユーザーがボタンをクリックしたときの動作について、関数型を各コンポーザブルに渡します。これにより、コンポーザブルとその子コンポーザブルは、関数を呼び出すタイミングを決定します。ただし、ナビゲーション ロジックはアプリの個々の画面には公開されません。ナビゲーション動作はすべて NavHost で処理されます。

StartOrderScreen にボタンハンドラを追加する

まず、最初の画面で数量ボタンのいずれかが押されたときに呼び出される関数型のパラメータを追加します。この関数は StartOrderScreen コンポーザブルに渡され、ビューモデルの更新と次の画面への移動を担います。

  1. StartOrderScreen.kt を開きます。
  2. quantityOptions パラメータの下、修飾子パラメータの前に、() -> Unit 型の onNextButtonClicked というパラメータを追加します。
@Composable
fun StartOrderScreen(
    quantityOptions: List<Pair<Int, Int>>,
    onNextButtonClicked: () -> Unit,
    modifier: Modifier = Modifier
){
    ...
}
  1. StartOrderScreen コンポーザブルが onNextButtonClicked の値を必要とするため、StartOrderPreview を見つけて空のラムダ本体を onNextButtonClicked パラメータに渡します。
@Preview
@Composable
fun StartOrderPreview() {
    CupcakeTheme {
        StartOrderScreen(
            quantityOptions = DataSource.quantityOptions,
            onNextButtonClicked = {},
            modifier = Modifier
                .fillMaxSize()
                .padding(dimensionResource(R.dimen.padding_medium))
        )
    }
}

各ボタンはそれぞれ異なる数量のカップケーキに対応しています。この情報は、onNextButtonClicked に渡される関数がそれに応じて適切にビューモデルを更新できるようにするために必要です。

  1. Int パラメータを受け取るように onNextButtonClicked パラメータの型を変更します。
onNextButtonClicked: (Int) -> Unit,

onNextButtonClicked() を呼び出すときに渡す Int を確認するために、quantityOptions パラメータの型を見てみましょう。

型は List<Pair<Int, Int>> か、Pair<Int, Int> のリストです。Pair 型にはなじみがないかもしれませんが、その名のとおり、値のペアです。Pair は汎用型パラメータを 2 つ受け取ります。この場合、どちらも Int 型です。

8326701a77706258.png

ペアの各アイテムには、1 つ目のプロパティまたは 2 つ目のプロパティでアクセスします。StartOrderScreen コンポーザブルの quantityOptions パラメータの場合、1 つ目の Int は、各ボタンに表示する文字列のリソース ID です。2 つ目の Int は、カップケーキの実際の数量です。

onNextButtonClicked() 関数を呼び出すときに、選択したペアの 2 つ目のプロパティを渡します。

  1. SelectQuantityButton の onClick パラメータの空のラムダ式を見つけます。
quantityOptions.forEach { item ->
    SelectQuantityButton(
        labelResourceId = item.first,
        onClick = {}
    )
}
  1. そのラムダ式で onNextButtonClicked を呼び出し、item.second(カップケーキの数)を渡します。
quantityOptions.forEach { item ->
    SelectQuantityButton(
        labelResourceId = item.first,
        onClick = { onNextButtonClicked(item.second) }
    )
}

SelectOptionScreen にボタンハンドラを追加する

  1. SelectOptionScreen.kt で、SelectOptionScreen コンポーザブルの onSelectionChanged パラメータの下に、() -> Unit 型の onCancelButtonClicked というパラメータを追加し、{} のデフォルト値を指定します。
@Composable
fun SelectOptionScreen(
    subtotal: String,
    options: List<String>,
    onSelectionChanged: (String) -> Unit = {},
    onCancelButtonClicked: () -> Unit = {},
    modifier: Modifier = Modifier
)
  1. onCancelButtonClicked パラメータの下に、() -> Unit 型の onNextButtonClicked という名前の別のパラメータを追加し、{} のデフォルト値を指定します。
@Composable
fun SelectOptionScreen(
    subtotal: String,
    options: List<String>,
    onSelectionChanged: (String) -> Unit = {},
    onCancelButtonClicked: () -> Unit = {},
    onNextButtonClicked: () -> Unit = {},
    modifier: Modifier = Modifier
)
  1. Cancel ボタンの onClick パラメータに onCancelButtonClicked を渡します。
OutlinedButton(
    modifier = Modifier.weight(1f),
    onClick = onCancelButtonClicked
) {
    Text(stringResource(R.string.cancel))
}
  1. Next ボタンの onClick パラメータに onNextButtonClicked を渡します。
Button(
    modifier = Modifier.weight(1f),
    enabled = selectedValue.isNotEmpty(),
    onClick = onNextButtonClicked
) {
    Text(stringResource(R.string.next))
}

SummaryScreen にボタンハンドラを追加する

最後に、概要画面の [Cancel] ボタンと [Send] ボタンのボタンハンドラ関数を追加します。

  1. SummaryScreen.kt の OrderSummaryScreen コンポーザブルに、() -> Unit 型の onCancelButtonClicked というパラメータを追加します。
@Composable
fun OrderSummaryScreen(
    orderUiState: OrderUiState,
    onCancelButtonClicked: () -> Unit,
    modifier: Modifier = Modifier
){
    ...
}
  1. (String, String) -> Unit 型のパラメータを追加し、名前を onSendButtonClicked にします。
@Composable
fun OrderSummaryScreen(
    orderUiState: OrderUiState,
    onCancelButtonClicked: () -> Unit,
    onSendButtonClicked: (String, String) -> Unit,
    modifier: Modifier = Modifier
){
    ...
}
  1. OrderSummaryScreen コンポーザブルによって onSendButtonClicked と onCancelButtonClicked の値が要求されます。OrderSummaryPreview を見つけて 2 つの String パラメータが設定された空のラムダ本体を onSendButtonClicked に渡し、空のラムダ本体を onCancelButtonClicked パラメータに渡します。
@Preview
@Composable
fun OrderSummaryPreview() {
   CupcakeTheme {
       OrderSummaryScreen(
           orderUiState = OrderUiState(0, "Test", "Test", "$300.00"),
           onSendButtonClicked = { subject: String, summary: String -> },
           onCancelButtonClicked = {},
           modifier = Modifier.fillMaxHeight()
       )
   }
}
  1. [Send] ボタンの onClick パラメータに onSendButtonClicked を渡します。newOrder と orderSummary(前に OrderSummaryScreen で定義した 2 つの変数)を渡します。これらの文字列は、ユーザーが別のアプリと共有できる実際のデータで構成されます。
Button(
    modifier = Modifier.fillMaxWidth(),
    onClick = { onSendButtonClicked(newOrder, orderSummary) }
) {
    Text(stringResource(R.string.send))
}
  1. [Cancel] ボタンの onClick パラメータに onCancelButtonClicked を渡します。
OutlinedButton(
    modifier = Modifier.fillMaxWidth(),
    onClick = onCancelButtonClicked
) {
    Text(stringResource(R.string.cancel))
}

別のルートに移動するには、単に NavHostController のインスタンスで navigate() メソッドを呼び出します。

fc8aae3911a6a25d.png

navigate メソッドは、1 つのパラメータ(NavHost で定義したルートに対応する String)を受け取ります。ルートが NavHost の composable() の呼び出しのいずれかと一致する場合、アプリはその画面に移動します。

Start、Flavor、Pickup の各画面でユーザーがボタンを押したときに、navigate() を呼び出す関数を渡します。

  1. CupcakeScreen.kt で、開始画面の composable() の呼び出しを見つけます。onNextButtonClicked パラメータにラムダ式を渡します。
StartOrderScreen(
    quantityOptions = DataSource.quantityOptions,
    onNextButtonClicked = {
    }
)

カップケーキの数について、この関数に渡した Int プロパティを覚えているでしょうか。次の画面に移動する前に、ビューモデルを更新してアプリに正しい小計が表示されるようにする必要があります。

  1. viewModel に対して setQuantity を呼び出し、it を渡します。
onNextButtonClicked = {
    viewModel.setQuantity(it)
}
  1. navController に対して navigate() を呼び出し、route に CupcakeScreen.Flavor.name を渡します。
onNextButtonClicked = {
    viewModel.setQuantity(it)
    navController.navigate(CupcakeScreen.Flavor.name)
}
  1. フレーバー画面の onNextButtonClicked パラメータについては、単に navigate() を呼び出すラムダを渡し、route に CupcakeScreen.Pickup.name を渡します。
composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
    SelectOptionScreen(
        subtotal = uiState.price,
        onNextButtonClicked = { navController.navigate(CupcakeScreen.Pickup.name) },
        options = DataSource.flavors.map { id -> context.resources.getString(id) },
        onSelectionChanged = { viewModel.setFlavor(it) },
        modifier = Modifier.fillMaxHeight()
    )
}
  1. 次に実装する onCancelButtonClicked に空のラムダを渡します。
SelectOptionScreen(
    subtotal = uiState.price,
    onNextButtonClicked = { navController.navigate(CupcakeScreen.Pickup.name) },
    onCancelButtonClicked = {},
    options = DataSource.flavors.map { id -> context.resources.getString(id) },
    onSelectionChanged = { viewModel.setFlavor(it) },
    modifier = Modifier.fillMaxHeight()
)
  1. 受け取り画面の onNextButtonClicked パラメータについては、navigate() を呼び出すラムダを渡し、route に CupcakeScreen.Summary.name を渡します。
composable(route = CupcakeScreen.Pickup.name) {
    SelectOptionScreen(
        subtotal = uiState.price,
        onNextButtonClicked = { navController.navigate(CupcakeScreen.Summary.name) },
        options = uiState.pickupOptions,
        onSelectionChanged = { viewModel.setDate(it) },
        modifier = Modifier.fillMaxHeight()
    )
}
  1. 再度、onCancelButtonClicked() に空のラムダを渡します。
SelectOptionScreen(
    subtotal = uiState.price,
    onNextButtonClicked = { navController.navigate(CupcakeScreen.Summary.name) },
    onCancelButtonClicked = {},
    options = uiState.pickupOptions,
    onSelectionChanged = { viewModel.setDate(it) },
    modifier = Modifier.fillMaxHeight()
)
  1. OrderSummaryScreen については、onCancelButtonClicked と onSendButtonClicked に空のラムダを渡します。onSendButtonClicked に渡される subject と summary のパラメータを追加します。これらはまもなく実装します。
composable(route = CupcakeScreen.Summary.name) {
    OrderSummaryScreen(
        orderUiState = uiState,
        onCancelButtonClicked = {},
        onSendButtonClicked = { subject: String, summary: String ->

        },
        modifier = Modifier.fillMaxHeight()
    )
}

これで、アプリの各画面を移動できるようになりました。navigate() を呼び出すと、画面が変更されるだけでなく、実際にバックスタックの上に配置されます。また、システムの「戻る」ボタンを押すと、前の画面に戻ることができます。

表示された画面は一つひとつ前の画面の上に積み重ねられ、「戻る」ボタン(bade5f3ecb71e4a2.png)で削除できます。一番下の startDestination から、表示されている一番上の画面までの履歴を、バックスタックといいます。

開始画面までポップする

システムの「戻る」ボタンとは異なり、[Cancel] ボタンを押しても前の画面に戻りません。バックスタックからすべての画面をポップ(削除)して、開始画面に戻る必要があります。

そのためには、popBackStack() メソッドを呼び出します。

2f382e5eb319b4b8.png

popBackStack() メソッドには必須のパラメータが 2 つあります。

  • route: 戻るデスティネーションのルートを表す文字列。
  • inclusive: ブール値。true の場合、指定したルートもポップ(削除)します。false の場合、popBackStack() は開始デスティネーションより上にあるデスティネーションをすべて削除し、ユーザーが目にする一番上の画面として開始デスティネーションを残します。

ユーザーがいずれかの画面で [Cancel] ボタンを押すと、アプリはビューモデルの状態をリセットし、popBackStack() を呼び出します。まずこれを行うメソッドを実装してから、[Cancel] ボタンを備えた 3 つの画面すべてで、適切なパラメータに渡します。

  1. CupcakeApp() 関数の後に、cancelOrderAndNavigateToStart() というプライベート関数を定義します。
private fun cancelOrderAndNavigateToStart() {
}
  1. OrderViewModel 型の viewModel と NavHostController 型の navController という 2 つのパラメータを追加します。
private fun cancelOrderAndNavigateToStart(
    viewModel: OrderViewModel,
    navController: NavHostController
) {
}
  1. 関数本体で、viewModel に対して resetOrder() を呼び出します。
private fun cancelOrderAndNavigateToStart(
    viewModel: OrderViewModel,
    navController: NavHostController
) {
    viewModel.resetOrder()
}
  1. navController に対して popBackStack() を呼び出し、route に CupcakeScreen.Start.name を渡して、inclusive に false を渡します。
private fun cancelOrderAndNavigateToStart(
    viewModel: OrderViewModel,
    navController: NavHostController
) {
    viewModel.resetOrder()
    navController.popBackStack(CupcakeScreen.Start.name, inclusive = false)
}
  1. CupcakeApp() コンポーザブルで、2 つの SelectOptionScreen コンポーザブルと OrderSummaryScreen コンポーザブルの onCancelButtonClicked パラメータに cancelOrderAndNavigateToStart を渡します。
composable(route = CupcakeScreen.Start.name) {
    StartOrderScreen(
        quantityOptions = DataSource.quantityOptions,
        onNextButtonClicked = {
            viewModel.setQuantity(it)
            navController.navigate(CupcakeScreen.Flavor.name)
        },
        modifier = Modifier
            .fillMaxSize()
            .padding(dimensionResource(R.dimen.padding_medium))
    )
}
composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
    SelectOptionScreen(
        subtotal = uiState.price,
        onNextButtonClicked = { navController.navigate(CupcakeScreen.Pickup.name) },
        onCancelButtonClicked = {
            cancelOrderAndNavigateToStart(viewModel, navController)
        },
        options = DataSource.flavors.map { id -> context.resources.getString(id) },
        onSelectionChanged = { viewModel.setFlavor(it) },
        modifier = Modifier.fillMaxHeight()
    )
}
composable(route = CupcakeScreen.Pickup.name) {
    SelectOptionScreen(
        subtotal = uiState.price,
        onNextButtonClicked = { navController.navigate(CupcakeScreen.Summary.name) },
        onCancelButtonClicked = {
            cancelOrderAndNavigateToStart(viewModel, navController)
        },
        options = uiState.pickupOptions,
        onSelectionChanged = { viewModel.setDate(it) },
        modifier = Modifier.fillMaxHeight()
    )
}
composable(route = CupcakeScreen.Summary.name) {
    OrderSummaryScreen(
        orderUiState = uiState,
        onCancelButtonClicked = {
            cancelOrderAndNavigateToStart(viewModel, navController)
        },
        onSendButtonClicked = { subject: String, summary: String ->

        },
        modifier = Modifier.fillMaxHeight()
   )
}
  1. アプリを実行し、いずれかの画面で [Cancel] ボタンを押すと最初の画面に戻ることを確認します。

6. 別のアプリに移動する

ここまでで、アプリ内の別の画面に移動する方法と、ホーム画面に戻る方法を学習しました。Cupcake アプリにナビゲーションを実装するには、もう 1 つだけステップがあります。注文概要画面で、ユーザーは別のアプリに注文を送信できます。この選択により、ShareSheet(画面の下部を覆うユーザー インターフェース コンポーネント)が表示され、共有オプションが表示されます。

この UI 部分は、Cupcake アプリの一部ではありません。実際は Android オペレーティング システムが提供しています。共有画面などのシステム UI は、navController で呼び出すわけではありません。代わりに、インテントというものを使用します。

インテントはシステムになんらかのアクションを行わせるリクエストです。通常は、新しいアクティビティを提示します。さまざまなインテントが存在します。包括的なリストについては、ドキュメントをご覧になることをおすすめします。ここでは、ACTION_SEND というインテントを活用します。このインテントに文字列などのデータを指定し、そのデータに適した共有アクションを提示できます。

インテントをセットアップする基本的なプロセスは次のとおりです。

  1. インテント オブジェクトを作成し、インテントを指定します(ACTION_SEND など)。
  2. インテントで送信する追加データの型を指定します。単純なテキストには "text/plain" を使用できますが、"image/*" や "video/*" など他のタイプも使用できます。
  3. putExtra() メソッドを呼び出して、共有するテキストや画像などの追加データをインテントに渡します。このインテントは EXTRA_SUBJECT と EXTRA_TEXT という 2 つのエクストラを受け取ります。
  4. コンテキストの startActivity() メソッドを呼び出し、インテントから作成したアクティビティを渡します。

ここでは共有アクション インテントの作成方法を説明しますが、プロセスは他のタイプのインテントでも同じです。今後のプロジェクトでは必要に応じて、特定の種類のデータと必要なエクストラについてドキュメントをご覧になることをおすすめします。

次の手順を実施して、カップケーキの注文を別のアプリに送信するインテントを作成します。

  1. CupcakeScreen.kt の CupcakeApp コンポーザブルの下に shareOrder() というプライベート関数を作成します。
private fun shareOrder()
  1. Context 型の context というパラメータを追加します。
import android.content.Context

private fun shareOrder(context: Context) {
}
  1. subject と summary の 2 つの String パラメータを追加します。これらの文字列は共有アクション シートに表示されます。
private fun shareOrder(context: Context, subject: String, summary: String) {
}
  1. 関数本体内に intent というインテントを作成し、引数として Intent.ACTION_SEND を渡します。
import android.content.Intent

val intent = Intent(Intent.ACTION_SEND)

この Intent オブジェクトは一度構成するだけで済むため、後続する数行のコードは、前の Codelab で学習した apply() 関数を使用してさらに簡潔にできます。

  1. 新しく作成したインテントに対して apply() を呼び出し、ラムダ式を渡します。
val intent = Intent(Intent.ACTION_SEND).apply {
    
}
  1. ラムダ本体で、型を "text/plain" に設定します。これは apply() に渡される関数で行うため、オブジェクトの識別子である intent を参照する必要はありません。
val intent = Intent(Intent.ACTION_SEND).apply {
    type = "text/plain"
}
  1. putExtra() を呼び出して、EXTRA_SUBJECT に subject を渡します。
val intent = Intent(Intent.ACTION_SEND).apply {
    type = "text/plain"
    putExtra(Intent.EXTRA_SUBJECT, subject)
}
  1. putExtra() を呼び出して、EXTRA_TEXT に summary を渡します。
val intent = Intent(Intent.ACTION_SEND).apply {
    type = "text/plain"
    putExtra(Intent.EXTRA_SUBJECT, subject)
    putExtra(Intent.EXTRA_TEXT, summary)
}
  1. コンテキストの startActivity() メソッドを呼び出します。
context.startActivity(
    
)
  1. startActivity() に渡されるラムダ内で、クラスメソッド createChooser() を呼び出してインテントからアクティビティを作成します。最初の引数と new_cupcake_order 文字列リソースに intent を渡します。
context.startActivity(
    Intent.createChooser(
        intent,
        context.getString(R.string.new_cupcake_order)
    )
)
  1. CupcakeApp コンポーザブルにおいて、CucpakeScreen.Summary.name の composable() の呼び出しで、コンテキスト オブジェクトへの参照を取得し、shareOrder() 関数に渡せるようにします。
composable(route = CupcakeScreen.Summary.name) {
    val context = LocalContext.current

    ...
}
  1. onSendButtonClicked() のラムダ本体で shareOrder() を呼び出し、引数として context、subject、summary を渡します。
onSendButtonClicked = { subject: String, summary: String ->
    shareOrder(context, subject = subject, summary = summary)
}
  1. アプリを実行し、画面を移動します。

[Send Order to Another App] をクリックすると、エクストラとして指定した件名と概要に加えて、[メッセージ] や [Bluetooth] などの共有アクションがボトムシートに表示されます。

13bde33712e135a4.png

7. アプリバーがナビゲーションに反応するようにする

アプリは機能し、どの画面間でも移動できるようにもなりましたが、この Codelab の冒頭で示したスクリーンショットと比べると欠けているものがまだいくつかあります。アプリバーは自動的にはナビゲーションに反応しません。アプリが新しいルートに移動したときにタイトルが更新されず、必要に応じてタイトルの前に「上へ」ボタンが表示されることもありません。

スターター コードには、CupcakeAppBar という AppBar を管理するためのコンポーザブルが含まれています。アプリにナビゲーションを実装したので、バックスタックからの情報を使用して正しいタイトルを表示し、必要に応じて「上へ」ボタンを表示できます。タイトルが適切に更新されるように CupcakeAppBar コンポーザブルは現在の画面を認識する必要があります。

  1. CupcakeScreen.kt の CupcakeScreen 列挙型で、@StringRes アノテーションを使用して Int 型の title という名前のパラメータを追加します。
import androidx.annotation.StringRes

enum class CupcakeScreen(@StringRes val title: Int) {
    Start,
    Flavor,
    Pickup,
    Summary
}
  1. 各画面のタイトル テキストに対応する各列挙型のリソース値を追加します。app_name を Start 画面に、choose_flavor を Flavor 画面に、choose_pickup_date を Pickup 画面に、さらに order_summary を Summary 画面に使用します。
enum class CupcakeScreen(@StringRes val title: Int) {
    Start(title = R.string.app_name),
    Flavor(title = R.string.choose_flavor),
    Pickup(title = R.string.choose_pickup_date),
    Summary(title = R.string.order_summary)
}
  1. CupcakeScreen 型の currentScreen というパラメータを CupcakeAppBar コンポーザブルに追加します。
fun CupcakeAppBar(
    currentScreen: CupcakeScreen,
    canNavigateBack: Boolean,
    navigateUp: () -> Unit = {},
    modifier: Modifier = Modifier
)
  1. CupcakeAppBar 内で、currentScreen.title を TopAppBar のタイトル パラメータの stringResource() の呼び出しに渡すことによって、ハードコードされたアプリ名を現在の画面のタイトルに置き換えます。
TopAppBar(
    title = { Text(stringResource(currentScreen.title)) },
    modifier = modifier,
    navigationIcon = {
        if (canNavigateBack) {
            IconButton(onClick = navigateUp) {
                Icon(
                    imageVector = Icons.Filled.ArrowBack,
                    contentDescription = stringResource(R.string.back_button)
                )
            }
        }
    }
)

「上へ」ボタンは、バックスタックにコンポーザブルがある場合にのみ表示されます。アプリの画面がバックスタックにない場合(StartOrderScreen が表示されている場合)、「上へ」ボタンは表示されません。これを確認するには、バックスタックへの参照が必要です。

  1. CupcakeApp コンポーザブルで、navController 変数の下に backStackEntry という変数を作成し、by デリゲートを使用して navController の currentBackStackEntryAsState() メソッドを呼び出します。
import androidx.navigation.compose.currentBackStackEntryAsState

@Composable
fun CupcakeApp(
    viewModel: OrderViewModel = viewModel(),
    navController: NavHostController = rememberNavController()
){

    val backStackEntry by navController.currentBackStackEntryAsState()

    ...
}
  1. 現在の画面のタイトルを CupcakeScreen の値に変換します。backStackEntry 変数の下で、CupcakeScreen の valueOf() クラス関数の呼び出し結果と同等の currentScreen という名前の val を使用して変数を作成し、backStackEntry の宛先のルートに渡します。エルビス演算子を使用して CupcakeScreen.Start.name のデフォルト値を指定します。
val currentScreen = CupcakeScreen.valueOf(
    backStackEntry?.destination?.route ?: CupcakeScreen.Start.name
)
  1. currentScreen 変数の値を同じ名前の CupcakeAppBar コンポーザブルのパラメータに渡します。
CupcakeAppBar(
    currentScreen = currentScreen,
    canNavigateBack = false,
    navigateUp = {}
)

バックスタックで現在の画面の背後に画面がある限り、「上へ」ボタンが表示されます。ブール式を使用して、「上へ」ボタンを表示するかどうかを指定できます。

  1. canNavigateBack パラメータに、navController の previousBackStackEntry プロパティが null と等しくないかどうかを確認するブール式を渡します。
canNavigateBack = navController.previousBackStackEntry != null,
  1. 実際に前の画面に戻るには、navController の navigateUp() メソッドを呼び出します。
navigateUp = { navController.navigateUp() }
  1. アプリを実行します。

AppBar のタイトルが現在の画面を反映するように更新されました。StartOrderScreen 以外の画面に移動すると「上へ」ボタンが表示され、前の画面に戻ることができます。

3fd023516061f522.gif

8. 解答コードを取得する

この Codelab の完成したコードをダウンロードするには、以下の git コマンドを使用します。

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-cupcake.git
$ cd basic-android-kotlin-compose-training-cupcake
$ git checkout navigation

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

この Codelab の解答コードを確認する場合は、GitHub で表示します。

9. まとめ

お疲れさまでした。簡単な単一画面のアプリから、Jetpack Navigation コンポーネントを使用して複数の画面間を移動する複雑なマルチスクリーン アプリに飛躍しました。ルートを定義し、NavHost で処理して、関数型のパラメータを使用してナビゲーション ロジックを個々の画面から分離しました。また、インテントを使用して別のアプリにデータを送信する方法や、ナビゲーションに応じてアプリバーをカスタマイズする方法も学習しました。今後のユニットでは、こうしたスキルを使用して、さらに複雑化するマルチスクリーン アプリに取り組んでいきます。

詳細