レスポンシブ ナビゲーションを構築する

ナビゲーションは、ユーザーがアプリの UI を操作してコンテンツ デスティネーションにアクセスする操作です。Android のナビゲーションの原則では、一貫性のある直感的なアプリ ナビゲーションの作成に役立つガイドラインが示されています。

レスポンシブ/アダプティブ UI は、レスポンシブなコンテンツ デスティネーションを提供します。多くの場合、ディスプレイのサイズの変化に応じて、さまざまなタイプのナビゲーション要素が含まれます(小画面では下部のナビゲーション バー、中程度の画面ではナビゲーション レール、大画面では常駐のナビゲーション ドロワーなど)。ただし、レスポンシブ/アダプティブ UI は、ナビゲーションの原則に準拠する必要があります。

Jetpack の Navigation コンポーネントはナビゲーションの原則を実装しているため、レスポンシブ/適応型 UI を備えたアプリを容易に開発できます。

図 1. ナビゲーション ドロワー、レール、ボトムバーを備えた拡大幅、中程度幅、コンパクト幅ディスプレイ

レスポンシブ UI ナビゲーション

アプリが占有するディスプレイ ウィンドウのサイズは、エルゴノミクスとユーザビリティに影響します。ウィンドウ サイズクラスを使用すると、適切なナビゲーション要素(ナビゲーション バー、レール、ドロワーなど)を決定でき、ユーザーが最もアクセスしやすい位置に配置できます。マテリアル デザインのレイアウト ガイドラインでは、ナビゲーション要素はディスプレイ前端の永続的なスペースを占有し、アプリの幅がコンパクトになると下端に移動できます。ナビゲーション要素の選択は、アプリ ウィンドウのサイズと、要素が保持する必要のある項目の数に大きく依存します。

ウィンドウ サイズクラス 項目が少ない 項目が多い
コンパクトな幅 ボトム ナビゲーション バー ナビゲーション ドロワー(前端または下端)
中程度の幅 ナビゲーション レール ナビゲーション ドロワー(前端)
拡大幅 ナビゲーション レール 永続ナビゲーション ドロワー(前端)

レイアウト リソース ファイルは、ウィンドウ サイズ クラスのブレークポイントで修飾して、ディスプレイ サイズごとに異なるナビゲーション要素を使用できます。

<!-- res/layout/main_activity.xml -->

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.bottomnavigation.BottomNavigationView
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        ... />

    <!-- Content view(s) -->
</androidx.constraintlayout.widget.ConstraintLayout>

<!-- res/layout-w600dp/main_activity.xml -->

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.navigationrail.NavigationRailView
        android:layout_width="wrap_content"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        ... />

    <!-- Content view(s) -->
</androidx.constraintlayout.widget.ConstraintLayout>

<!-- res/layout-w1240dp/main_activity.xml -->

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.navigation.NavigationView
        android:layout_width="wrap_content"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        ... />

    <!-- Content view(s) -->
</androidx.constraintlayout.widget.ConstraintLayout>

レスポンシブ コンテンツ デスティネーション

レスポンシブ UI では、各コンテンツ デスティネーションのレイアウトはウィンドウ サイズの変化に適応します。アプリでは、レイアウト間隔の調整、要素の再配置、コンテンツの追加または削除、UI 要素の変更(ナビゲーション要素を含む)ができます。

個々のデスティネーションがサイズ変更イベントを処理する場合、変更は UI に切り分けられます。ナビゲーションを含め、アプリのその他の状態は影響を受けません。

ウィンドウ サイズ変更の副作用としてナビゲーションが生じてはなりません。さまざまなウィンドウ サイズに対応するためだけにコンテンツ デスティネーションを作成することは避けてください。たとえば、折りたたみ式デバイスの画面ごとに異なるコンテンツ デスティネーションを作成しないでください。

ウィンドウ サイズ変更の副作用としてコンテンツ デスティネーションに移動すると、次のような問題が発生します。

  • 新しいデスティネーションに移動する前に、古いデスティネーション(以前のウィンドウ サイズ用)が一瞬表示されることがある
  • デバイスを折りたたみ、広げるときなど、可逆性を維持するには、ウィンドウ サイズごとにナビゲーションが必要になる
  • ナビゲーションではバックスタックをポップした際に状態が破棄される可能性があるため、デスティネーション間でアプリの状態を維持することは困難な場合がある

また、ウィンドウ サイズの変更が行われている間、アプリがフォアグラウンドにないこともあります。対象アプリのレイアウトにフォアグラウンド アプリよりも広いスペースが必要な場合があり、ユーザーがアプリに戻ったときに、画面の向きやウィンドウ サイズがすべて変更されている可能性があります。

アプリでウィンドウ サイズに基づいた一意のコンテンツ デスティネーションが必要な場合は、関連するデスティネーションを、代替の適応型レイアウトを含む単一のデスティネーションにまとめることを検討してください。

代替レイアウトを伴うコンテンツ デスティネーション

レスポンシブ/アダプティブ デザインの一環として、単一のナビゲーション デスティネーションに、アプリのウィンドウ サイズに応じた代替レイアウトを設定できます。各レイアウトはウィンドウ全体を占めますが、ウィンドウ サイズによって異なるレイアウトが表示されます(アダプティブ デザイン)。

典型的な例は、リスト詳細ビューです。ウィンドウ サイズがコンパクトな場合、アプリはリスト用と詳細用のコンテンツ レイアウトを 1 つずつ表示します。リスト詳細ビューのデスティネーションに移動すると、最初はリスト レイアウトのみが表示されます。リスト項目を選択すると、リスト レイアウトに代わって詳細レイアウトが表示されます。戻るコントロールを選択すると、詳細レイアウトに代わってリスト レイアウトが表示されます。ただし、ウィンドウ サイズを拡大した場合は、リスト レイアウトと詳細レイアウトが並んで表示されます。

SlidingPaneLayout を使用すると、大画面では 2 つのコンテンツ ペインを並べて表示し、従来のスマートフォンなどの小画面では一度に 1 つのペインだけを表示する、単一のナビゲーション デスティネーションを作成できます。

<!-- Single destination for list and detail. -->

<navigation ...>

    <!-- Fragment that implements SlidingPaneLayout. -->
    <fragment
        android:id="@+id/article_two_pane"
        android:name="com.example.app.ListDetailTwoPaneFragment" />

    <!-- Other destinations... -->
</navigation>

SlidingPaneLayout を使用してリスト詳細レイアウトを実装する方法については、2 ペインのレイアウトを作成するをご覧ください。

1 つのナビゲーション グラフ

あらゆるデバイスやウィンドウ サイズで一貫したユーザー エクスペリエンスを実現するには、各コンテンツ デスティネーションのレイアウトがレスポンシブである、単一のナビゲーション グラフを使用します。

ウィンドウ サイズ クラスごとに異なるナビゲーション グラフを使用する場合、あるサイズクラスから別のサイズクラスにアプリが遷移するたびに、他のグラフにおけるユーザーの現在のデスティネーションを決定し、バックスタックを作成して、グラフ間で異なる状態情報を調整する必要があります。

ネストされたナビゲーション ホスト

アプリに含まれるコンテンツ デスティネーションが、それ自体のコンテンツ デスティネーションを持つ場合があります。たとえば、リスト詳細レイアウトでは、項目の詳細を置き換えるコンテンツに移動する UI 要素を項目の詳細ペインに含めることができます。

このようなサブナビゲーションを実装するには、詳細ペイン自体のナビゲーション グラフで詳細ペインからアクセスされるデスティネーションを指定して、詳細ペインをネストされたナビゲーション ホストにします。

<!-- layout/two_pane_fragment.xml -->

<androidx.slidingpanelayout.widget.SlidingPaneLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/sliding_pane_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/list_pane"
        android:layout_width="280dp"
        android:layout_height="match_parent"
        android:layout_gravity="start"/>

    <!-- Detail pane is a nested navigation host. Its graph is not connected
         to the main graph that contains the two_pane_fragment destination. -->
    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/detail_pane"
        android:layout_width="300dp"
        android:layout_weight="1"
        android:layout_height="match_parent"
        android:name="androidx.navigation.fragment.NavHostFragment"
        app:navGraph="@navigation/detail_pane_nav_graph" />
</androidx.slidingpanelayout.widget.SlidingPaneLayout>

ネストされた NavHost のナビゲーション グラフはメインのナビゲーション グラフに接続されていないため、これはネストされたナビゲーション グラフとは異なります。つまり、あるグラフのデスティネーションから別のグラフのデスティネーションに直接移動することはできません。

詳細については、ネストされたナビゲーション グラフをご覧ください。

状態の保持

レスポンシブ コンテンツ デスティネーションを提供するには、デバイスの回転や折りたたみを行ったとき、またはアプリ ウィンドウをサイズ変更したとき、アプリの状態を保持する必要があります。デフォルトでは、こうした構成変更により、アプリのアクティビティ、フラグメント、ビュー階層が再作成されます。UI の状態を保存するには、構成変更後も存続する ViewModel を使用することをおすすめします。(UI の状態を保存する をご覧ください)。

ユーザーがデバイスを回転させてから再び回転させた場合など、サイズ変更は元に戻せなければなりません。

レスポンシブ レイアウト/アダプティブ レイアウトでは、さまざまなウィンドウ サイズでさまざまなコンテンツを表示できます。そのため、多くの場合、状態が現在のウィンドウ サイズに該当しないものであっても、コンテンツに関連する追加の状態を保存する必要があります。たとえば、レイアウトに、ウィンドウ幅が大きくなったときにのみ追加のスクロール ウィジェットを表示するスペースができるとします。サイズ変更イベントによってウィンドウの幅が小さくなりすぎると、ウィジェットは非表示になります。アプリが元の寸法にサイズ変更されると、スクロール ウィジェットが再び表示され、元のスクロール位置が復元されます。

ViewModel のスコープ

Navigation コンポーネントに移行するデベロッパー ガイドでは、デスティネーションがフラグメントとして実装され、そのデータモデルが ViewModel を使用して実装される、単一アクティビティ アーキテクチャが推奨されています。

ViewModel のスコープは常にライフサイクルに設定されており、ライフサイクルが完全に終了すると ViewModel消去され、破棄できるようになります。ViewModel のスコープが設定されているライフサイクル(つまり ViewModel をどの程度広く共有できるか)は、ViewModel の取得に使用するプロパティ デリゲートによって異なります。

最も単純なケースでは、すべてのナビゲーション デスティネーションが、完全に分離された UI 状態を持つ単一のフラグメントになります。そのため、各フラグメントは viewModels() プロパティのデリゲートを使用して、そのフラグメントにスコープ設定された ViewModel を取得できます。

フラグメント間で UI の状態を共有するには、フラグメント内で activityViewModels() を呼び出して、ViewModel をアクティビティにスコープ設定します(Activity と同等のものは viewModels() です)。これにより、アクティビティとそれにアタッチされたフラグメントが ViewModel インスタンスを共有できるようになります。ただし、単一アクティビティ アーキテクチャでは、この ViewModel のスコープはアプリが存続している限り有効であるため、フラグメントが使用されていない場合でも ViewModel がメモリ内に残ります。

ナビゲーション グラフに、購入手続きのフローを表す一連のフラグメント デスティネーションがあり、購入手続き全体の現在の状態は、フラグメント間で共有される ViewModel にあるとします。この ViewModel のスコープをアクティビティに設定しても、対象範囲が広すぎるだけでなく、実際に別の問題が発生します。ユーザーが 1 回目の注文で購入手続きフローを通った後、2 回目の注文で再度通った場合、どちらの回でも購入手続き ViewModel の同じインスタンスが使用されます。2 回目の注文の購入手続きより前に、最初の注文のデータを手動で消去する必要があります。誤りがあると、ユーザーに損害を与える可能性があります。

代わりに、ViewModel を現在の NavController のナビゲーション グラフにスコープ設定します。ネストされたナビゲーション グラフを作成して、購入手続きフローの一部であるデスティネーションをカプセル化します。次に、それぞれのフラグメント デスティネーションで navGraphViewModels() プロパティ デリゲートを使用し、ナビゲーション グラフの ID を渡して共有 ViewModel を取得します。これにより、ユーザーが購入手続きフローを終了し、ネストされたナビゲーション グラフがスコープ外になると、対応する ViewModel のインスタンスが破棄され、次の購入手続きで使用されなくなります。

範囲 プロパティ デリゲート ViewModel を共有できる相手
Fragment Fragment.viewModels() フラグメントのみ
アクティビティ Activity.viewModels() または Fragment.activityViewModels() アクティビティとそれにアタッチされているすべてのフラグメント
ナビゲーション グラフ Fragment.navGraphViewModels() 同じナビゲーション グラフのすべてのフラグメント

なお、ネストされたナビゲーション ホストを使用している場合(ネストされたナビゲーション ホストのセクションを参照)、そのホストのデスティネーションは、navGraphViewModels() を使用すると、そのホスト外のデスティネーションと ViewModel インスタンスを共有できません(グラフが接続されていないため)。この場合、代わりにアクティビティ スコープを使用できます。

参考情報