ナビゲーションとバックスタック

1. 始める前に

この Codelab では、前回の Codelab で実装を開始した Cupcake アプリの残りの部分を完成させます。Cupcake アプリには複数の画面があり、カップケーキの注文フローが表示されます。完成したアプリでは、ユーザーはアプリで次の操作を行えます。

  • カップケーキの注文を作成する
  • 上へボタンまたは戻るボタンを使用して、注文フローの前のステップに移動する
  • 注文をキャンセルする
  • メールアプリなどの別のアプリに注文を送信する

実装の過程で、Android がアプリのタスクとバックスタックを処理する方法を学びます。学習を通じて、注文をキャンセルしたユーザーを(注文フローの前の画面ではなく)アプリの最初の画面に戻すなどのシナリオで、バックスタックを操作できるようになります。

前提条件

  • アクティビティ内のフラグメント間で共有ビューモデルを作成し、使用できる
  • Jetpack Navigation コンポーネントの使用に精通している
  • UI とビューモデルの同期を維持するために LiveData とのデータ バインディングを使用した経験がある
  • 新しいアクティビティを開始するインテントを作成できる

学習内容

  • ナビゲーションがアプリのバックスタックに与える影響
  • カスタムのバックスタック動作を実装する方法

作成するアプリの概要

  • 別のアプリへの注文の送信と、注文のキャンセルができるカップケーキ注文アプリ

必要なもの

  • Android Studio がインストールされているパソコン
  • 前回の Codelab を完了した時点の Cupcake アプリのコード

2. スターター アプリの概要

この Codelab では、前回の Codelab で作成した Cupcake アプリを使用します。前回の Codelab を完了した時点のコードを使用するか、GitHub からスターター コードをダウンロードしてください。

この Codelab のスターター コードをダウンロードする

GitHub からスターター コードをダウンロードした場合、プロジェクトのフォルダ名は android-basics-kotlin-cupcake-app-viewmodel になります。Android Studio でプロジェクトを開くときは、このフォルダを選択してください。

この Codelab のコードを取得して Android Studio で開くには、以下の手順に沿って操作します。

コードを取得する

  1. 指定された URL をクリックします。プロジェクトの GitHub ページがブラウザで開きます。
  2. プロジェクトの GitHub ページで、[Code] ボタンをクリックすると、ダイアログが表示されます。

5b0a76c50478a73f.png

  1. ダイアログで、[Download ZIP] をクリックして、プロジェクトをパソコンに保存します。ダウンロードが完了するまで待ってください。
  2. パソコンに保存したファイルを見つけます([ダウンロード] フォルダなど)。
  3. ZIP ファイルをダブルクリックして展開します。プロジェクト ファイルが入った新しいフォルダが作成されます。

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

  1. Android Studio を起動します。
  2. [Welcome to Android Studio] ウィンドウで [Open an existing Android Studio project] をクリックします。

36cc44fcf0f89a1d.png

注: Android Studio がすでに開いている場合は、メニューから [File] > [New] > [Import Project] を選択します。

21f3eec988dcfbe9.png

  1. [Import Project] ダイアログで、展開したプロジェクト フォルダがある場所([ダウンロード] フォルダなど)に移動します。
  2. そのプロジェクト フォルダをダブルクリックします。
  3. Android Studio でプロジェクトが開くまで待ちます。
  4. 実行ボタン 11c34fc5e516fb1c.png をクリックし、アプリをビルドして実行します。正常にビルドされたことを確認します。
  5. [Project] ツール ウィンドウでプロジェクト ファイルを見て、アプリがどのように設定されているかを確認します。

アプリを実行すると、次のようになります。

45844688c0dc69a2.png

この Codelab では、最初に上へボタンをアプリに実装し、ユーザーがこのボタンをタップして注文フローの前のステップに移動できるようにします。

fbdc1793f9fea6da.png

次に、キャンセル ボタンを追加して、注文手続き中に気が変わった場合に注文をキャンセルできるようにします。

d66fdafeac1b0dcf.gif

さらに、アプリを拡張して、[Send Order to Another App] をタップすると別のアプリと注文を共有できるようにします。たとえば、ケーキ店にメールで注文を送信します。

170d76b64ce78f56.png

それでは、Cupcake アプリを詳しく検討して完成させましょう。

3.上へボタンの動作を実装する

Cupcake アプリでは、前の画面に戻る矢印がアプリバーに表示されます。これは、前の Codelab で学習したとおり、上へボタンと呼ばれます。現在、アプリの上へボタンは何も実行しないので、このナビゲーションのバグを修正しましょう。

fbdc1793f9fea6da.png

  1. MainActivity には、ナビゲーション コントローラを使用してアプリバー(アクションバーとも呼びます)を設定するコードがすでに存在します。navController をクラス変数にして、別のメソッドでも使用できるようにします。
class MainActivity : AppCompatActivity(R.layout.activity_main) {

    private lateinit var navController: NavController

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val navHostFragment = supportFragmentManager
                .findFragmentById(R.id.nav_host_fragment) as NavHostFragment
        navController = navHostFragment.navController

        setupActionBarWithNavController(navController)
    }
}
  1. 同じクラス内に、onSupportNavigateUp() 関数をオーバーライドするコードを追加します。このコードは、アプリ内を遡るナビゲーションを処理することを navController に要求します。失敗した場合は、上へボタンを処理するスーパークラス実装(AppCompatActivity 内にあります)にフォールバックします。
override fun onSupportNavigateUp(): Boolean {
   return navController.navigateUp() || super.onSupportNavigateUp()
}
  1. アプリを実行します。FlavorFragmentPickupFragmentSummaryFragment から、上へボタンが機能するはずです。注文フローの前のステップに戻ると、フラグメントはビューモデルからの正しいフレーバーと受け取り日を表示します。

4. タスクとバックスタックについて学ぶ

次に、アプリの注文フローにキャンセル ボタンを導入します。注文手続きの任意の時点で注文をキャンセルすると、ユーザーは StartFragment に戻ります。この動作を処理するため、Android のタスクとバックスタックについて学習しましょう。

タスク

Android のアクティビティはタスク内に存在します。ランチャー アイコンから初めてアプリを開くと、Android はメイン アクティビティを含む新しいタスクを作成します。「タスク」とは、ユーザーが特定のジョブ(メールの確認、カップケーキの注文の作成、写真の撮影など)を実行するときに操作するアクティビティのコレクションです。

アクティビティは、「バックスタック」と呼ばれるスタック内に配列されます。ユーザーが新しいアクティビティにアクセスするたびに、各アクティビティはタスクのバックスタックにプッシュされます。これは、積み重なったパンケーキに喩えることができます。新しいパンケーキは前のパンケーキの上に積み重ねられます。スタックの一番上にあるアクティビティは、ユーザーが現在操作しているアクティビティです。その下にあるアクティビティはバックグラウンドに移動され、停止されています。

517054e483795b46.png

バックスタックは、ユーザーが前の画面に戻りたいときに役立ちます。Android は、現在のアクティビティをスタックの一番上から取り除いて破棄し、その下にあったアクティビティを再開できます。言い換えると、スタックからアクティビティを「ポップ」し、前のアクティビティをフォアグラウンドに移動してユーザーが操作できるようにします。ユーザーがいくつか前の画面に戻りたい場合、Android はアクティビティをスタックの一番上からポップし続け、スタックの一番下に近づきます。バックスタックにアクティビティがなくなったら、ユーザーはデバイス(またはアクティビティを起動したアプリ)のランチャー画面に戻されます。

2 つのアクティビティ(MainActivityDetailActivity)を実装した Words アプリのバージョンを見てみましょう。

アプリを初めて起動すると、MainActivity が開いて、タスクのバックスタックに追加されます。

4bc8f5aff4d5ee7f.png

文字をクリックすると、DetailActivity が起動して、バックスタックにプッシュされます。これは、DetailActivity が作成、開始、再開され、ユーザーが操作できるようになったことを意味します。MainActivity はバックグラウンドに移動されます。以下の図ではグレーの背景色で示されています。

80f7c594ae844b84.png

戻るボタンをタップすると、DetailActivity がバックスタックからポップされ、DetailActivity インスタンスは破棄されて終了します。

80f532af817191a4.png

その後、バックスタックの一番上にある次のアイテム(MainActivity)がフォアグラウンドに移動されます。

85004712d2fbcdc1.png

バックスタックは、ユーザーが開始したアクティビティをトラッキングできるのと同様に、Jetpack Navigation コンポーネントを利用して、ユーザーがアクセスしたフラグメント デスティネーションをトラッキングすることもできます。

fe417ac5cbca4ce7.png

Navigation ライブラリを使用すると、ユーザーが戻るボタンをタップするたびに、バックスタックからフラグメント デスティネーションをポップできます。このデフォルトの動作は無償で提供され、実装する必要はありません。コードを記述しなければならないのは、カスタムのバックスタック動作が必要な場合だけです(Cupcake アプリではカスタムの動作が必要です)。

Cupcake アプリのデフォルトの動作

それでは、Cupcake アプリでバックスタックがどのように機能するかを見てみましょう。アプリにアクティビティは 1 つしかありませんが、ユーザーがナビゲートするフラグメント デスティネーションは複数あります。そのため、戻るボタンがタップされるたびに、直前のフラグメント デスティネーションに戻ることが求められます。

初めてアプリを開いたときは、StartFragment デスティネーションが表示されます。このデスティネーションは、スタックの一番上にプッシュされます。

cf0e80b4907d80dd.png

1 個のカップケーキの注文を選択すると、FlavorFragment に移動し、このフラグメントはバックスタックにプッシュされます。

39081dcc3e537e1e.png

フレーバーを選択して次へをタップすると、PickupFragment に移動し、このフラグメントはバックスタックにプッシュされます。

37dca487200f8f73.png

最後に、受け取り日を選択して次へをタップすると、SummaryFragment に移動し、このフラグメントはバックスタックの一番上に追加されます。

d67689affdfae0dd.png

SummaryFragment から戻るボタンまたは上へボタンをタップします。すると、SummaryFragment がスタックからポップされ、破棄されます。

215b93fd65754017.png

PickupFragment がバックスタックの一番上になり、ユーザーに表示されます。

37dca487200f8f73.png

戻るボタンまたは上へボタンを再度タップします。PickupFragment がスタックからポップされ、FlavorFragment が表示されます。

戻るボタンまたは上へボタンを再度タップします。FlavorFragment がスタックからポップされ、StartFragment が表示されます。

注文フローの前のステップに戻る過程では、一度に 1 つのデスティネーションのみがポップされます。しかし、次のタスク(注文をキャンセルする機能をアプリに追加します)では、ユーザーを StartFragment に戻して新しい注文を開始するために、バックスタック内の複数のデスティネーションを一度にポップする必要があります。

e3dae0f492450207.png

Cupcake アプリのバックスタックを変更する

注文のキャンセル ボタンをユーザーに提供するため、FlavorFragmentPickupFragment、および SummaryFragment クラスとレイアウト ファイルを変更します。

ナビゲーション アクションを追加する

まず、アプリのナビゲーション グラフにナビゲーション アクションを追加して、ユーザーが StartFragment 以降のデスティネーションからそこに戻れるようにします。

  1. res > navigation > nav_graph.xml ファイルに移動してデザインビューを選択し、Navigation Editor を開きます。
  2. 現在は、startFragment から flavorFragment へのアクション、flavorFragment から pickupFragment へのアクション、pickupFragment から summaryFragment へのアクションがあります。
  3. クリックしてドラッグし、summaryFragment から startFragment への新しいナビゲーション アクションを作成します。ナビゲーション グラフでデスティネーションを接続する方法を復習したい場合は、こちらの手順をご覧ください。
  4. pickupFragment からクリックしてドラッグし、startFragment への新しいアクションを作成します。
  5. flavorFragment からクリックしてドラッグし、startFragment への新しいアクションを作成します。
  6. 完了すると、ナビゲーション グラフは次のようになります。

dcbd27a08d24cfa0.png

以上の変更により、ユーザーは注文フローの途中のフラグメントから注文フローの開始点に戻れるようになります。これらのアクションを実際にナビゲートするコードが必要です。コードを挿入する適切なポイントは、キャンセル ボタンがタップされたときです。

キャンセル ボタンをレイアウトに追加する

まず、StartFragment を除くすべてのフラグメントのレイアウト ファイルにキャンセル ボタンを追加します。すでに注文フローの最初の画面にいる場合、注文をキャンセルする必要はありません。

  1. fragment_flavor.xml レイアウト ファイルを開きます。
  2. 分割ビューを使用して、XML を直接編集し、プレビューを横に並べて表示します。
  3. 小計のテキストビューと次へボタンの間にキャンセル ボタンを追加します。ボタンにリソース ID @+id/cancel_button を割り当て、@string/cancel として表示されるテキストを含めます。

次へボタンの横にボタンを配置し、ボタンの行として表示されるようにします。縦方向の制約については、キャンセル ボタンの最上部を次へボタンの最上部に制限します。横方向の制約については、キャンセル ボタンの開始位置を親コンテナに制限し、終了位置を次へボタンの開始位置に制限します。

また、キャンセル ボタンの高さを wrap_content にし、幅を 0dp にして、画面の幅を他のボタンと均等に分割します。なお、次のステップが終わるまで、[Preview] パネルにボタンは表示されません。

...

<TextView
    android:id="@+id/subtotal" ... />

<Button
    android:id="@+id/cancel_button"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:text="@string/cancel"
    app:layout_constraintEnd_toStartOf="@id/next_button"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="@id/next_button" />

<Button
    android:id="@+id/next_button" ... />

...
  1. また、fragment_flavor.xml で、次へボタンの開始制約を app:layout_constraintStart_toStartOf="parent から app:layout_constraintStart_toEndOf="@id/cancel_button" に変更する必要があります。さらに、キャンセル ボタンに終了余白を追加して、2 つのボタンの間に空白を設けます。これで、Android Studio の [Preview] パネルにキャンセル ボタンが表示されるようになります。
...

<Button
    android:id="@+id/cancel_button"
    android:layout_marginEnd="@dimen/side_margin" ... />

<Button
    android:id="@+id/next_button"
    app:layout_constraintStart_toEndOf="@id/cancel_button"... />

...
  1. 視覚的スタイルについては、マテリアルの枠線付きボタンスタイル(属性 style="?attr/materialButtonOutlinedStyle" を含む)を適用し、キャンセル ボタンが次へボタンほど目立たないようにします。ユーザーの注意を引き付けたいメイン アクションは次に進む操作であるからです。
<Button
    android:id="@+id/cancel_button"
    style="?attr/materialButtonOutlinedStyle" ... />

これで、ボタンの外観と配置が適切なものになりました。

1fb41763cc255c05.png

  1. 同様に、fragment_pickup.xml レイアウト ファイルにキャンセル ボタンを追加します。
...

<TextView
    android:id="@+id/subtotal" ... />

<Button
    android:id="@+id/cancel_button"
    style="?attr/materialButtonOutlinedStyle"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:layout_marginEnd="@dimen/side_margin"
    android:text="@string/cancel"
    app:layout_constraintEnd_toStartOf="@id/next_button"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="@id/next_button" />

<Button
    android:id="@+id/next_button" ... />

...
  1. 次へボタンの開始制約も同様に更新します。これにより、プレビューにキャンセル ボタンが表示されます。
<Button
    android:id="@+id/next_button"
    app:layout_constraintStart_toEndOf="@id/cancel_button" ... />
  1. fragment_summary.xml ファイルにも同様の変更を適用しますが、このフラグメントのレイアウトには少し違いがあります。親の縦方向の LinearLayout送信ボタンの下にキャンセル ボタンを追加し、これらのボタンの間に余白を挿入します。

741c0f034397795c.png

...

    <Button
        android:id="@+id/send_button" ... />

    <Button
        android:id="@+id/cancel_button"
        style="?attr/materialButtonOutlinedStyle"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="@dimen/margin_between_elements"
        android:text="@string/cancel" />

</LinearLayout>
  1. アプリを実行してテストします。FlavorFragmentPickupFragmentSummaryFragment のレイアウトにキャンセル ボタンが表示されるはずです。ただし、ボタンをタップしても、まだ何も起こりません。次のステップで、これらのボタンのクリック リスナーを設定します。

キャンセル ボタンのクリック リスナーを追加する

各フラグメント クラス(StartFragment を除く)内に、キャンセル ボタンのクリックを処理するヘルパー メソッドを追加します。

  1. この cancelOrder() メソッドを FlavorFragment に追加します。フレーバー オプションが表示されたとき、ユーザーが注文をキャンセルすることにした場合は sharedViewModel.resetOrder(). を呼び出してビューモデルを消去し、ID が R.id.action_flavorFragment_to_startFragment. のナビゲーション アクションを使用して StartFragment に戻ります。
fun cancelOrder() {
    sharedViewModel.resetOrder()
    findNavController().navigate(R.id.action_flavorFragment_to_startFragment)
}

アクション リソース ID に関連するエラーが表示された場合は、nav_graph.xml ファイルに戻って、ナビゲーション アクションの名前が同じ(action_flavorFragment_to_startFragment)であるかどうかを確認します。

  1. リスナー バインディングを使用して、fragment_flavor.xml レイアウトのキャンセル ボタンにクリック リスナーを設定します。このボタンをクリックすると、先ほど FragmentFlavor クラスに作成した cancelOrder() メソッドが呼び出されます。
<Button
    android:id="@+id/cancel_button"
    android:onClick="@{() -> flavorFragment.cancelOrder()}" ... />
  1. PickupFragment について同じプロセスを繰り返します。cancelOrder() メソッドをフラグメント クラスに追加します。このメソッドは、注文をリセットして PickupFragment から StartFragment にナビゲートします。
fun cancelOrder() {
    sharedViewModel.resetOrder()
    findNavController().navigate(R.id.action_pickupFragment_to_startFragment)
}
  1. fragment_pickup.xml で、キャンセル ボタンのクリック リスナーを設定して、クリック時に cancelOrder() メソッドを呼び出すようにします。
<Button
    android:id="@+id/cancel_button"
    android:onClick="@{() -> pickupFragment.cancelOrder()}" ... />
  1. SummaryFragmentキャンセル ボタンに同様のコードを追加し、ユーザーを StartFragment に戻すようにします。androidx.navigation.fragment.findNavController が自動的にインポートされない場合は、インポートする必要があります。.
fun cancelOrder() {
    sharedViewModel.resetOrder()
    findNavController().navigate(R.id.action_summaryFragment_to_startFragment)
}
  1. fragment_summary.xml で、キャンセル ボタンがクリックされたときに、SummaryFragmentcancelOrder() メソッドを呼び出します。
<Button
    android:id="@+id/cancel_button"
    android:onClick="@{() -> summaryFragment.cancelOrder()}" ... />
  1. アプリを実行してテストし、各フラグメントに追加したロジックを検証します。カップケーキの注文の作成中に、FlavorFragmentPickupFragmentSummaryFragmentキャンセル ボタンをタップすると、StartFragment に戻ります。新しい注文の作成を進めると、前回の注文の情報が消去されていることに気づくはずです。

これは正常な動作に見えますが、実際には、StartFragment に戻ってから逆向きにナビゲートする際に、バグが発生します。バグを再現するため、次のステップを実施します。

  1. カップケーキの新しい注文を作成する注文フローを、概要画面が表示されるまで進めます。たとえば、12 個のカップケーキを注文し、チョコレート フレーバーを指定して、受け取り日を選択します。
  2. キャンセルをタップします。StartFragment に戻るはずです。
  3. 正常な動作に見えますが、システムの戻るボタンをタップすると、注文概要画面が 0 個のカップケーキ、フレーバーなしの状態で表示されます。これは誤りであり、ユーザーに表示するべきではありません。

1a9024cd58a0e643.png

ユーザーが望んだのは、注文フローを遡ることではないはずです。さらに、ビューモデルからの注文データがすべて消去されているので、表示される情報は役に立ちません。StartFragment から戻るボタンがタップされたら、Cupcake アプリが終了することが求められます。

バックスタックが現在どのようになっているかを見て、バグを修正する方法を検討しましょう。注文概要画面で注文を作成すると、各デスティネーションがバックスタックにプッシュされます。

fc88100cdf1bdd1.png

SummaryFragment から注文をキャンセルしました。SummaryFragment から StartFragment へのアクションによってナビゲートすると、Android は新しいデスティネーションとして StartFragment の別のインスタンスをバックスタックに追加しました。

5616cb0028b63602.png

そのため、StartFragment から戻るボタンをタップすると、アプリは(空の注文情報で)SummaryFragment を再度表示しました。

このナビゲーションのバグを修正するため、アクションによりナビゲートする際に Navigation コンポーネントを使用してバックスタックから追加のデスティネーションをポップする方法を学びます。

バックスタックから追加のデスティネーションをポップする

ナビゲーション グラフでナビゲーション アクションに app:popUpTo 属性を含めると、指定されたデスティネーションに到達するまで、複数のデスティネーションをバックスタックからポップできます。app:popUpTo="@id/startFragment" を指定すると、StartFragment に到達するまで、バックスタック内のデスティネーションがポップされ、StartFragment はスタックに残ります。

この変更をコードに追加してアプリを実行すると、注文をキャンセルしたときに StartFragment に戻るようになります。しかし今度は、StartFragment から戻るボタンをタップすると、(アプリが終了するのではなく)StartFragment が再び表示されるようになります。これも望ましい動作ではありません。前述のように、StartFragment にナビゲートしたので、Android は実際には新しいデスティネーションとして StartFragment をバックスタックに追加します。そのため、バックスタックに StartFragment のインスタンスが 2 つ存在することになります。したがって、アプリを終了するには戻るボタンを 2 回タップする必要があります。

dd0fedc6e231e595.png

この新たなバグを修正するには、StartFragment までのすべてのデスティネーションをバックスタックからポップすることをリクエストします。これを行うには、app:popUpTo="@id/startFragment"

app:popUpToInclusive="true" を適切なナビゲーション アクションに指定します。そうすると、バックスタック内に StartFragment の新しいインスタンスが 1 つだけ作成されます。これにより、StartFragment から戻るボタンを一度タップすると、アプリが終了します。実際に変更してみましょう。

cf0e80b4907d80dd.png

ナビゲーション アクションを変更する

  1. res > navigation > nav_graph.xml ファイルを開いて、Navigation Editor に移動します。
  2. summaryFragment から startFragment にナビゲートするアクションを選択します(青色でハイライト表示されます)。
  3. 右側の [Attributes] を展開します(まだ展開されていない場合)。変更できる属性のリストで、[Pop Behavior] を探します。

8c87589f9cc4d176.png

  1. プルダウン オプションで、[popUpTo] を startFragment に設定します。これは、バックスタック内のすべてのデスティネーションが(スタックの一番上から)startFragment までポップされることを意味します。

a9a17493ed6bc27f.png

  1. 次に、チェックマークと [true] のラベルが表示されるまで、[popUpToInclusive] のチェックボックスをクリックします。これは、バックスタックにすでに存在する startFragment のインスタンスを含めてデスティネーションをポップすることを示します。これにより、バックスタックに startFragment のインスタンスが 2 つ存在することがなくなります。

4a403838a62ff487.png

  1. pickupFragmentstartFragment に接続するアクションについて、上記の変更を繰り返します。

4a403838a62ff487.png

  1. flavorFragmentstartFragment に接続するアクションについて、上記の手順を繰り返します。
  2. 完了したら、ナビゲーション グラフ ファイルのコードビューを表示して、アプリが正しく変更されていることを確認します。
<navigation
    android:id="@+id/nav_graph" ...>
    <fragment
        android:id="@+id/startFragment" ...>
        ...
    </fragment>
    <fragment
        android:id="@+id/flavorFragment" ...>
        ...
        <action
            android:id="@+id/action_flavorFragment_to_startFragment"
            app:destination="@id/startFragment"
            app:popUpTo="@id/startFragment"
            app:popUpToInclusive="true" />
    </fragment>
    <fragment
        android:id="@+id/pickupFragment" ...>
        ...
        <action
            android:id="@+id/action_pickupFragment_to_startFragment"
            app:destination="@id/startFragment"
            app:popUpTo="@id/startFragment"
            app:popUpToInclusive="true" />
    </fragment>
    <fragment
        android:id="@+id/summaryFragment" ...>
        <action
            android:id="@+id/action_summaryFragment_to_startFragment"
            app:destination="@id/startFragment"
            app:popUpTo="@id/startFragment"
            app:popUpToInclusive="true" />
    </fragment>
</navigation>

3 つのアクション(action_flavorFragment_to_startFragmentaction_pickupFragment_to_startFragmentaction_summaryFragment_to_startFragment)のそれぞれに、新たに追加した属性 app:popUpTo="@id/startFragment" および app:popUpToInclusive="true" があるはずです。

  1. アプリを実行します。注文フローを進めて、キャンセルをタップします。StartFragment に戻ったら、戻るボタンを一度だけタップして、アプリが終了することを確認します。

何が起こったかを要約します。注文をキャンセルしてアプリの最初の画面に戻ったとき、バックスタック内のすべてのフラグメント デスティネーション(StartFragment の最初のインスタンスを含む)がスタックからポップされました。ナビゲーション アクションが完了すると、StartFragment が新しいデスティネーションとしてバックスタックに追加されました。そこから戻るボタンをタップすると、StartFragment がスタックからポップされ、バックスタックにフラグメントがなくなりました。そのため、Android はアクティビティを終了し、ユーザーの画面でアプリが閉じられました。

アプリは次のように表示されます。2e0599d9b55401f1.png

5. 注文を送信する

以上で、アプリはかなり良くなりました。しかし、改良できる点がもうひとつあります。SummaryFragment で注文送信ボタンをタップすると、依然として Toast メッセージが表示されます。

90ed727c7b812fd6.png

アプリから注文を送信できれば、使い勝手がもっと良くなります。前の Codelab で学んだことを活用し、暗黙的インテントを使用してアプリと別のアプリの間で情報を共有しましょう。そうすれば、ユーザーはカップケーキの注文情報をデバイス上のメールアプリと共有して、ケーキ店に注文メールを送れるようになります。

170d76b64ce78f56.png

この機能を実装するため、上のスクリーンショットでメールの件名と本文の構造を確認してください。

これらの文字列は strings.xml ファイルにすでに含まれており、そこから使用できます。

<string name="new_cupcake_order">New Cupcake Order</string>
<string name="order_details">Quantity: %1$s cupcakes \n Flavor: %2$s \nPickup date: %3$s \n Total: %4$s \n\n Thank you!</string>

order_details は、4 つの異なる書式引数を含む文字列リソースです。各引数は、カップケーキの実際の数量、希望するフレーバー、希望する受け取り日、合計金額のプレースホルダです。引数には 1 から 4 までの番号が付いており、構文は %1%4 です。引数の型も指定されています($s は文字列が期待されることを意味します)。

Kotlin コードでは、R.string.order_details の後に 4 つの引数を指定して、getString() を呼び出すことができます(順序が重要です)。たとえば、getString(R.string.order_details, "12", "Chocolate", "Sat Dec 12", "$24.00") を呼び出すと、次の文字列が作成されます。これは、まさに目的のメール本文です。

Quantity: 12 cupcakes
Flavor: Chocolate
Pickup date: Sat Dec 12
Total: $24.00

Thank you!
  1. SummaryFragment.kt で、sendOrder() メソッドを変更します。既存の Toast メッセージを削除します。
fun sendOrder() {

}
  1. sendOrder() メソッド内で、注文概要テキストを作成します。共有ビューモデルから、注文数量、フレーバー、日付、金額を取得して、書式を持つ order_details 文字列を作成します。
val orderSummary = getString(
    R.string.order_details,
    sharedViewModel.quantity.value.toString(),
    sharedViewModel.flavor.value.toString(),
    sharedViewModel.date.value.toString(),
    sharedViewModel.price.value.toString()
)
  1. 引き続き sendOrder() メソッド内で、他のアプリと注文を共有する暗黙的インテントを作成します。メール インテントを作成する方法については、ドキュメントをご覧ください。インテント アクションに Intent.ACTION_SEND を指定し、型を "text/plain" に設定して、インテント エクストラをメールの件名(Intent.EXTRA_SUBJECT)とメールの本文(Intent.EXTRA_TEXT)用に含めます。必要であれば android.content.Intent をインポートします。
val intent = Intent(Intent.ACTION_SEND)
    .setType("text/plain")
    .putExtra(Intent.EXTRA_SUBJECT, getString(R.string.new_cupcake_order))
    .putExtra(Intent.EXTRA_TEXT, orderSummary)

ここで特別なヒントをお教えしましょう。このアプリを独自のユースケースに合わせて調整する場合、メールの受信者を事前入力してケーキ店のメールアドレスを指定できます。インテント内で、インテント エクストラ Intent.EXTRA_EMAIL を使用してメール受信者を指定します。

  1. これは暗黙的インテントであるため、このインテントを処理する特定のコンポーネントまたはアプリを事前に認識する必要はありません。ユーザーは、インテントの実行に使用したいアプリを自分で決定できます。ただし、このインテントでアクティビティを起動する前に、それを処理できるアプリがあるかどうかをチェックしてください。このチェックにより、インテントを処理するアプリがない場合に Cupcake アプリがクラッシュすることが回避され、コードの安全性が向上します。
if (activity?.packageManager?.resolveActivity(intent, 0) != null) {
    startActivity(intent)
}

このチェックを行うには、デバイスにインストールされているアプリ パッケージに関する情報が含まれている PackageManager にアクセスします。activitypackageManager が null でなければ、フラグメントの activity を介して PackageManager にアクセスできます。作成したインテントで、PackageManagerresolveActivity() メソッドを呼び出します。結果が null でなければ、インテントを使用して安全に startActivity() を呼び出すことができます。

  1. アプリを実行してコードをテストします。カップケーキの注文を作成し、[Send Order to Another App] をタップします。共有ダイアログが表示されたら、Gmail アプリを選択できます。または、必要に応じて別のアプリも選択できます。Gmail アプリを選択した場合、まだアカウントを設定していなければ、設定が必要になります(たとえばエミュレータを使用している場合)。メール本文に最新のカップケーキの注文が表示されない場合は、最初に現在のメールの下書きを破棄する必要があります。

170d76b64ce78f56.png

さまざまなシナリオでテストすると、カップケーキを 1 個だけ注文する場合のバグに気づくはずです。注文概要に「1 cupcakes」と表示されますが、これは英語では文法的に誤りです。

ef046a100381bb07.png

正しくは「1 cupcake」(単数形)でなければなりません。数量の値に基づいて「cupcake」または「cupcakes」のいずれかを選択したい場合は、Android の数量文字列という機能を使用できます。plurals リソースを宣言すると、数量の値に応じて、異なる文字列リソース(たとえば単数形と複数形)を指定できます。

  1. strings.xml ファイルに plurals リソース cupcakes を追加します。
<plurals name="cupcakes">
    <item quantity="one">%d cupcake</item>
    <item quantity="other">%d cupcakes</item>
</plurals>

単数の場合(quantity="one")は、単数形の文字列が使用されます。それ以外の場合(quantity="other")は、複数形の文字列が使用されます。文字列の引数を期待する %s と異なり、%d は整数の引数を期待します。この引数は、文字列の書式設定の際に渡します。

Kotlin コードで、次の関数を呼び出します。

getQuantityString(R.plurals.cupcakes, 1, 1) は文字列 1 cupcake を返します。

getQuantityString(R.plurals.cupcakes, 6, 6) は文字列 6 cupcakes を返します。

getQuantityString(R.plurals.cupcakes, 0, 0) は文字列 0 cupcakes を返します。

  1. Kotlin コードに移る前に、strings.xmlorder_details 文字列リソースを更新して、ハードコードされた複数形の cupcakes を削除します。
<string name="order_details">Quantity: %1$s \n Flavor: %2$s \nPickup date: %3$s \n
        Total: %4$s \n\n Thank you!</string>
  1. SummaryFragment クラスで、sendOrder() メソッドを更新して新しい数量文字列を使用します。最初にビューモデルから数量を見つけて、それを変数に格納する方法が最も簡単です。ビューモデルの quantity は型が LiveData<Int> であるため、sharedViewModel.quantity.value が null になる可能性があります。null の場合は、numberOfCupcakes のデフォルト値として 0 を使用します。

このロジックを sendOrder() メソッドの最初の行のコードとして追加します。

val numberOfCupcakes = sharedViewModel.quantity.value ?: 0

elvis 演算子(?:)は、左側の式が null でない場合はそれを使用することを意味します。左側の式が null の場合は、elvis 演算子の右側の式(この場合は 0)が使用されます。

  1. 次に、先ほどと同様に order_details 文字列を書式設定します。numberOfCupcakes を数量引数として直接渡す代わりに、resources.getQuantityString(R.plurals.cupcakes, numberOfCupcakes, numberOfCupcakes) を使用して、書式設定されたカップケーキ文字列を作成します。

完全な sendOrder() メソッドは次のようになります。

fun sendOrder() {
    val numberOfCupcakes = sharedViewModel.quantity.value ?: 0
    val orderSummary = getString(
        R.string.order_details,
        resources.getQuantityString(R.plurals.cupcakes, numberOfCupcakes, numberOfCupcakes),
        sharedViewModel.flavor.value.toString(),
        sharedViewModel.date.value.toString(),
        sharedViewModel.price.value.toString()
    )

    val intent = Intent(Intent.ACTION_SEND)
        .setType("text/plain")
        .putExtra(Intent.EXTRA_SUBJECT, getString(R.string.new_cupcake_order))
        .putExtra(Intent.EXTRA_TEXT, orderSummary)

    if (activity?.packageManager?.resolveActivity(intent, 0) != null) {
        startActivity(intent)
    }
}
  1. コードを実行してテストします。メール本文の注文概要に、「1 cupcake」、「6 cupcakes」、または「12 cupcakes」と表示されることを確認します。

以上で、Cupcake アプリのすべての機能の実装が完了しました。おめでとうございます。これは挑戦しがいのあるアプリであり、あなたは Android 開発者になるための道のりにおいて飛躍的な進歩を遂げました。これまでに学んだすべてのコンセプトをうまく組み合わせることができただけでなく、その過程で問題解決の新しいヒントをいくつか得ることもできました。

最終ステップ

ここでは、コードのクリーンアップを行います。これは、前の Codelab で学んだように、コーディングの際の良い習慣です。

  • インポートを最適化する
  • ファイルを再フォーマットする
  • 使用されていないコードまたはコメントアウトされたコードを削除する
  • 必要に応じてコードにコメントを追加する

アプリのユーザー補助機能を強化するため、Talkback を有効にしてアプリをテストし、ユーザー エクスペリエンスがスムーズかどうかを確認します。音声フィードバックは、必要な場合に、画面上の各要素の目的をユーザーに伝達するのに役立ちます。また、スワイプ操作を使用してアプリのすべての要素をナビゲートできることを確認します。

実装したユースケースが最終版のアプリで想定どおりに動作することを再確認します。動作の例を次に示します。

  • デバイスを回転させたときに(ビューモデルの機能により)データが保持される。
  • 上へボタンまたは戻るボタンをタップしたとき、FlavorFragmentPickupFragment で引き続き注文情報が正しく表示される。
  • 別のアプリに注文を送信したとき、正しい注文詳細が共有される。
  • 注文をキャンセルすると、その注文に含まれるすべての情報が消去される。

バグが見つかった場合は修正します。

おつかれさまです。以上で作業の再確認が問題なく完了しました。

6. 解答コード

この Codelab の解答コードは、下記のプロジェクトにあります。

この Codelab のコードを取得して Android Studio で開くには、以下の手順に沿って操作します。

コードを取得する

  1. 指定された URL をクリックします。プロジェクトの GitHub ページがブラウザで開きます。
  2. プロジェクトの GitHub ページで、[Code] ボタンをクリックすると、ダイアログが表示されます。

5b0a76c50478a73f.png

  1. ダイアログで、[Download ZIP] をクリックして、プロジェクトをパソコンに保存します。ダウンロードが完了するまで待ってください。
  2. パソコンに保存したファイルを見つけます([ダウンロード] フォルダなど)。
  3. ZIP ファイルをダブルクリックして展開します。プロジェクト ファイルが入った新しいフォルダが作成されます。

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

  1. Android Studio を起動します。
  2. [Welcome to Android Studio] ウィンドウで [Open an existing Android Studio project] をクリックします。

36cc44fcf0f89a1d.png

注: Android Studio がすでに開いている場合は、メニューから [File] > [New] > [Import Project] を選択します。

21f3eec988dcfbe9.png

  1. [Import Project] ダイアログで、展開したプロジェクト フォルダがある場所([ダウンロード] フォルダなど)に移動します。
  2. そのプロジェクト フォルダをダブルクリックします。
  3. Android Studio でプロジェクトが開くまで待ちます。
  4. 実行ボタン 11c34fc5e516fb1c.png をクリックし、アプリをビルドして実行します。正常にビルドされたことを確認します。
  5. [Project] ツール ウィンドウでプロジェクト ファイルを見て、アプリがどのように設定されているかを確認します。

7. 概要

  • Android は、ユーザーがアクセスしたすべてのデスティネーションをバックスタックに保存し、それぞれの新しいデスティネーションをスタックにプッシュします。
  • 上へボタンまたは戻るボタンをタップすると、バックスタックからデスティネーションをポップできます。
  • Jetpack Navigation コンポーネントを使用すると、戻るボタンのデフォルトの動作を無償で利用して、フラグメント デスティネーションをバックスタックにプッシュし、バックスタックからポップできます。
  • ナビゲーション グラフでアクションに app:popUpTo 属性を指定すると、属性値で指定されたデスティネーションまでのデスティネーションをバックスタックからポップできます。
  • app:popUpTo で指定したデスティネーションもバックスタックからポップする必要がある場合は、アクションに app:popUpToInclusive="true" を指定します。
  • 暗黙的インテントを作成してメールアプリとコンテンツを共有するには、Intent.ACTION_SEND を使用し、Intent.EXTRA_EMAILIntent.EXTRA_SUBJECTIntent.EXTRA_TEXT などのインテント エクストラを指定します。
  • 数量に応じて異なる文字列リソース(単数形または複数形)を使用する場合は、plurals リソースを使用します。

8. 詳細

9. 自習用練習問題

Cupcake アプリを拡張して、カップケーキの注文フローに独自のバリエーションを加えてください。次に例を示します。

  • 当日に受け取れないといった特別な条件がある特別なフレーバーを提供する。
  • カップケーキを注文するユーザーの名前を尋ねる。
  • 注文するカップケーキの数量が 1 個を超える場合、ユーザーがカップケーキのフレーバーを複数選択できるようにする。

こうした新しい機能に対応するために、アプリの更新が必要になる部分はどこでしょうか?

確認:

完成したアプリはエラーなしで動作する必要があります。

10. チャレンジ タスク

Cupcake アプリの作成を通して学んだことを活用して、独自のユースケース向けのアプリを作成してください。そのアプリでは、ピザやサンドイッチなど、いろいろなものを注文できます。実装を開始する前に、アプリのさまざまなデスティネーションの概要を決めておくことをおすすめします。

他のデザインのアイデアからインスピレーションを得るには、Shrine アプリが参考になります。これは、独自のブランドにマテリアルのテーマ設定とコンポーネントを導入する方法を示すマテリアルの練習用アプリです。Shrine アプリは、ここで作成した Cupcake アプリよりはるかに複雑です。したがって、最初から難しいアプリの構築を目指すのではなく、まずは小さな機能に取り組むことを検討してください。漸進的かつ段階的な成功を積み重ねて、徐々に自信を付けていきましょう。

独自のアプリが完成したら、作品をソーシャル メディアで共有しましょう。ハッシュタグ #LearningKotlin を付けて、検索できるようにしてください。