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

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

レスポンシブ UI はレスポンシブ コンテンツ デスティネーションを提供します。多くの場合、ディスプレイ サイズの変更に応じてさまざまな種類のナビゲーション要素が含まれます(小型ディスプレイでのボトム ナビゲーション バー、中型ディスプレイでのナビゲーション レール、大型ディスプレイでの永続ナビゲーション ドロワーなど)。なお、レスポンシブ UI は、ナビゲーションの原則を遵守する必要があります。

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

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

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

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

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

ビューベースのレイアウトでは、ウィンドウ サイズ クラスのブレークポイントでレイアウト リソース ファイルを修飾して、ディスプレイ サイズごとに異なるナビゲーション要素を使用できます。Jetpack Compose では、ウィンドウ サイズクラスの API が提供するブレークポイントを使用して、アプリ ウィンドウに最適なナビゲーション要素をプログラムで特定できます。

ビュー

<!-- 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>

Compose

// This method should be run inside a Composable function.
val widthSizeClass = calculateWindowSizeClass(this).widthSizeClass
// You can get the height of the current window by invoking heightSizeClass instead.

@Composable
fun MyApp(widthSizeClass: WindowWidthSizeClass) {
    // Select a navigation element based on window size.
    when (widthSizeClass) {
        WindowWidthSizeClass.Compact -> { CompactScreen() }
        WindowWidthSizeClass.Medium -> { MediumScreen() }
        WindowWidthSizeClass.Expanded -> { ExpandedScreen() }
    }
}

@Composable
fun CompactScreen() {
    Scaffold(bottomBar = {
                NavigationBar {
                    icons.forEach { item ->
                        NavigationBarItem(
                            selected = isSelected,
                            onClick = { ... },
                            icon = { ... })
                    }
                }
            }
        ) {
        // Other content
    }
}

@Composable
fun MediumScreen() {
    Row(modifier = Modifier.fillMaxSize()) {
        NavigationRail {
            icons.forEach { item ->
                NavigationRailItem(
                    selected = isSelected,
                    onClick = { ... },
                    icon = { ... })
            }
        }
        // Other content
    }
}

@Composable
fun ExpandedScreen() {
    PermanentNavigationDrawer(
        drawerContent = {
            icons.forEach { item ->
                NavigationDrawerItem(
                    icon = { ... },
                    label = { ... },
                    selected = isSelected,
                    onClick = { ... }
                )
            }
        },
        content = {
            // Other content
        }
    )
}

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

レスポンシブ UI では、各コンテンツ デスティネーションのレイアウトを、ウィンドウ サイズの変化に適応させる必要があります。アプリでは、レイアウト間隔の調整、要素の再配置、コンテンツの追加または削除、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 ペインのレイアウトを作成するをご覧ください。

Compose

Compose でリスト詳細ビューを実装するには、ウィンドウ サイズ クラスを使用して各サイズクラスに適したコンポーザブルを出力する単一のルートで代替コンポーザブルを組み合わせます。

ルートは、コンテンツ デスティネーションへのナビゲーション パスです。通常は単一のコンポーザブルですが、代替コンポーザブルにすることもできます。ビジネス ロジックにより、表示する代替コンポーザブルが決定されます。どの代替コンポーザブルが表示されるかにかかわらず、コンポーザブルはアプリ ウィンドウ全体に表示されます。

たとえば、リスト詳細ビューは、次のように 3 つのコンポーザブルで構成されます。

/* Displays a list of items. */
@Composable
fun ListOfItems(
    onItemSelected: (String) -> Unit,
) { /*...*/ }

/* Displays the detail for an item. */
@Composable
fun ItemDetail(
    selectedItemId: String? = null,
) { /*...*/ }

/* Displays a list and the detail for an item side by side. */
@Composable
fun ListAndDetail(
    selectedItemId: String? = null,
    onItemSelected: (String) -> Unit,
) {
  Row {
    ListOfItems(onItemSelected = onItemSelected)
    ItemDetail(selectedItemId = selectedItemId)
  }
}

単一のナビゲーション ルートでリスト詳細ビューにアクセスできます。

@Composable
fun ListDetailRoute(
    // Indicates that the display size is represented by the expanded window size class.
    isExpandedWindowSize: Boolean = false,
    // Identifies the item selected from the list. If null, a item has not been selected.
    selectedItemId: String?,
) {
  if (isExpandedWindowSize) {
    ListAndDetail(
      selectedItemId = selectedItemId,
      /*...*/
    )
  } else {
    // If the display size cannot accommodate both the list and the item detail,
    // show one of them based on the user's focus.
    if (selectedItemId != null) {
      ItemDetail(
        selectedItemId = selectedItemId,
        /*...*/
      )
    } else {
      ListOfItems(/*...*/)
    }
  }
}

ListDetailRoute(ナビゲーション デスティネーション)により、3 つのコンポーザブルのうちどれを出力するかが決定されます(拡大ウィンドウ サイズの場合は ListAndDetail、コンパクト ウィンドウ サイズの場合はリスト項目が選択されているかどうかに応じて ListOfItems または ItemDetail)。

たとえば、次のように、ルートは NavHost に含まれています。

NavHost(navController = navController, startDestination = "listDetailRoute") {
  composable("listDetailRoute") {
    ListDetailRoute(isExpandedWindowSize = isExpandedWindowSize,
                    selectedItemId = selectedItemId)
  }
  /*...*/
}

isExpandedWindowSize 引数を指定するには、アプリの WindowMetrics を調べます。

selectedItemId 引数は、すべてのウィンドウ サイズにわたって状態を維持する ViewModel で指定できます。ユーザーがリストの項目を選択すると、selectedItemId 状態変数が更新されます。

class ListDetailViewModel : ViewModel() {

  data class ListDetailUiState(
      val selectedItemId: String? = null,
  )

  private val viewModelState = MutableStateFlow(ListDetailUiState())

  fun onItemSelected(itemId: String) {
    viewModelState.update {
      it.copy(selectedItemId = itemId)
    }
  }
}

val listDetailViewModel = ListDetailViewModel()

@Composable
fun ListDetailRoute(
    isExpandedWindowSize: Boolean = false,
    selectedItemId: String?,
    onItemSelected: (String) -> Unit = { listDetailViewModel.onItemSelected(it) },
) {
  if (isExpandedWindowSize) {
    ListAndDetail(
      selectedItemId = selectedItemId,
      onItemSelected = onItemSelected,
      /*...*/
    )
  } else {
    if (selectedItemId != null) {
      ItemDetail(
        selectedItemId = selectedItemId,
        /*...*/
      )
    } else {
      ListOfItems(
        onItemSelected = onItemSelected,
        /*...*/
      )
    }
  }
}

項目の詳細コンポーザブルがアプリ ウィンドウ全体を占有する場合、ルートにはカスタム BackHandler も含まれます。

class ListDetailViewModel : ViewModel() {

  data class ListDetailUiState(
      val selectedItemId: String? = null,
  )

  private val viewModelState = MutableStateFlow(ListDetailUiState())

  fun onItemSelected(itemId: String) {
    viewModelState.update {
      it.copy(selectedItemId = itemId)
    }
  }

  fun onItemBackPress() {
    viewModelState.update {
      it.copy(selectedItemId = null)
    }
  }
}

val listDetailViewModel = ListDetailViewModel()

@Composable
fun ListDetailRoute(
    isExpandedWindowSize: Boolean = false,
    selectedItemId: String?,
    onItemSelected: (String) -> Unit = { listDetailViewModel.onItemSelected(it) },
    onItemBackPress: () -> Unit = { listDetailViewModel.onItemBackPress() },
) {
  if (isExpandedWindowSize) {
    ListAndDetail(
      selectedItemId = selectedItemId,
      onItemSelected = onItemSelected,
      /*...*/
    )
  } else {
    if (selectedItemId != null) {
      ItemDetail(
        selectedItemId = selectedItemId,
        /*...*/
      )
      BackHandler {
        onItemBackPress()
      }
    } else {
      ListOfItems(
        onItemSelected = onItemSelected,
        /*...*/
      )
    }
  }
}

ViewModel のアプリ状態とウィンドウ サイズ クラス情報を組み合わせると、シンプルなロジックで適切なコンポーザブルを選択できるようになります。単方向データフローを維持することで、アプリの状態を保持しながら、利用可能な表示領域を完全に使用できます。

Compose での完全なリスト詳細ビューの実装については、GitHub の JetNews サンプルをご覧ください。

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>

Compose

@Composable
fun ItemDetail(selectedItemId: String? = null) {
    val navController = rememberNavController()
    NavHost(navController, "itemSubdetail1") {
        composable("itemSubdetail1") { ItemSubdetail1(...) }
        composable("itemSubdetail2") { ItemSubdetail2(...) }
        composable("itemSubdetail3") { ItemSubdetail3(...) }
    }
}

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

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

状態の保持

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

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

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

ViewModel のスコープ

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

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

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

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

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

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

スコープ プロパティ デリゲート ViewModel を共有できる相手
フラグメント Fragment.viewModels() 現在のフラグメントのみ
アクティビティ Activity.viewModels()

Fragment.activityViewModels()

アクティビティとそれにアタッチされているすべてのフラグメント
ナビゲーション グラフ Fragment.navGraphViewModels() 同じナビゲーション グラフのすべてのフラグメント

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

状態のホイスト

Compose では、状態ホイスティングを使用してウィンドウ サイズ変更時に状態を保持できます。コンポーザブルの状態をコンポジション ツリー内のより高い位置にホイストすると、コンポーザブルが表示されなくなっても状態を保持できます。

上記の代替レイアウトを伴うコンテンツ デスティネーションCompose セクションでは、リスト詳細ビューのコンポーザブルの状態を ListDetailRoute にホイストして、どのコンポーザブルが表示されるかにかかわらず状態が保持されるようにしています。

@Composable
fun ListDetailRoute(
    // Indicates that the display size is represented by the expanded window size class.
    isExpandedWindowSize: Boolean = false,
    // Identifies the item selected from the list. If null, a item has not been selected.
    selectedItemId: String?,
) { /*...*/ }

参考情報