アダプティブ UI のナビゲーションを実装する

ナビゲーションとは、ユーザーがアプリ内のさまざまなコンテンツ間を行き来する操作を指します。Compose のアダプティブ UI は基本的にはナビゲーションのプロセスを変更せず、デベロッパーは引き続きすべてのナビゲーションの原則を遵守する必要があります。ナビゲーションのコンポーネントを使用すると、推奨パターンを簡単に適用でき、適応性の高いレイアウトのアプリで引き続き使用することが可能です。

上述の原則に加えて、アダプティブ レイアウトのアプリでユーザー エクスペリエンスを高めるためには、他にもいくつかの考慮事項があります。アダプティブ レイアウトの作成のガイドで説明されているように、UI の構造はアプリで利用可能なスペースによって異なる可能性があります。これらの追加のナビゲーションの原則はすべて、アプリで利用できる画面スペースが変更された場合の動作を考慮します。

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

最適なナビゲーション エクスペリエンスをユーザーに提供するには、アプリで利用できるスペースに合わせて調整されたナビゲーション UI を提供する必要があります。ボトム アプリバー、常時表示または折りたたみ可能なナビゲーション ドロワーレールの使用をおすすめします。または、利用可能な画面スペースやアプリ独自のスタイルに応じてまったく新しい要素を使用することもできます。

これらのコンポーネントは画面の幅または高さいっぱいに表示されるため、どちらを使用するかは画面レベルのレイアウトの決定に左右されます。そのため、表示するナビゲーション UI のタイプを決定するには、ウィンドウ サイズクラスを使用することをおすすめします。ウィンドウ サイズクラスは、ほとんどの独自のケースに合わせてアプリを最適化できる柔軟性とシンプルさのバランスを保つように設計されたブレークポイントです。

enum class WindowSizeClass { Compact, Medium, Expanded }

@Composable
fun MyApp(windowSizeClass: WindowSizeClass) {
    // Perform logic on the size class to decide whether to show
    // the nav rail
    val showNavRail = windowSizeClass != WindowSizeClass.Compact
    MyScreen(
        showNavRail = showNavRail,
        /* ... */
    )
}

完全にレスポンシブなデスティネーション

Chrome OS のマルチウィンドウ モード、折りたたみ式ウィンドウ、フリーフォーム ウィンドウは、いずれもアプリが利用できるスペースをこれまで以上に変化させます。

シームレスなユーザー エクスペリエンスを提供するために、ナビゲーション ホスト内で、各デスティネーションがレスポンシブ単一のナビゲーション グラフを使用します。このアプローチにより、レスポンシブ UI の主要原則(柔軟性と継続性)が強化されます。個々のデスティネーションがサイズ変更イベントを適切に処理する場合、変更は UI のみに切り分けられ、アプリのその他の状態(ナビゲーションを含む)は保持されるため、継続性が高まります。

並列ナビゲーション グラフでは、アプリが別のサイズクラスに遷移するたびに、別のグラフでユーザーの現在のデスティネーションを判断し、バックスタックを再構築して、グラフ間で異なる他の状態情報を調整する必要があります。このアプローチは複雑で、エラーが発生しやすくなります。

特定のデスティネーションでは、さまざまな方法でレイアウトをレスポンシブにできます。間隔の調整、代替レイアウトの使用、情報列の追加によるスペース確保、スペースに収まらない追加の詳細情報の表示を行うことができます。これらの変更を実装するために利用できるツールについて詳しくは、アダプティブ レイアウトの作成をご覧ください。

ユーザー エクスペリエンスをさらに向上させるために、大画面の正規レイアウト(リスト表示または詳細表示など)を使用して、特定のデスティネーションにコンテンツを追加できます。このような設計のナビゲーションに関する考慮事項を以下に示します。

ルートと画面の区別

ナビゲーション コンポーネントを使用すると、デスティネーションに対応するそれぞれのルートを定義できます。ナビゲーションにより、現在表示されているデスティネーションが変更されるとともに、ユーザーが以前にいたデスティネーションのリストである、バックスタックをトラッキングします。

特定のデスティネーションで任意のコンテンツを表示できます。アプリのメイン ナビゲーションを処理する NavHost の場合、通常はデスティネーションごとに異なる画面を表示するため、アプリで使用可能なスペース全体が占有されます。

ほとんどの場合、各デスティネーションが 1 つの画面を表示する役割を担い、各画面は 1 つのデスティネーションにのみ表示されます。ただし、これは必須ではありません。実際には、アプリで使用可能なサイズに応じてデスティネーションが複数の画面から表示する画面を選択できるようにすることは非常に有用です。

Compose の公式サンプルの一つである JetNews を見てみましょう。このアプリの主な機能は、ユーザーがリストから選択できる記事の表示です。アプリに十分なスペースがある場合は、リストと記事の両方を同時に表示できます。このインターフェースはリストまたは詳細レイアウトで、マテリアル デザインの正規レイアウトの一つです。

JetNews のリスト画面、詳細画面、リストおよび詳細画面

これらは視覚的に異なる 3 つの画面ですが、アプリは同じ "home" ルートの下に 3 つの画面すべてを表示しています。

コード内で、デスティネーションが HomeRoute を呼び出します。

@Composable
fun JetnewsNavGraph(
    navController: NavHostController,
    isExpandedScreen: Boolean,
    // ...
) {
    // ...
    NavHost(
        navController = navController,
        startDestination = JetnewsDestinations.HomeRoute
    ) {
        composable(JetnewsDestinations.HomeRoute) {
            // ...
            HomeRoute(
                isExpandedScreen = isExpandedScreen,
                // ...
            )
        }
        // ...
    }
}

次に、HomeRoute コードは Screen の接尾辞が付いた各コンポーザブルである 3 つの画面のうち、どれを表示するかを決定します。アプリは、HomeViewModel に保存されているアプリの状態と、現在の使用可能なスペースを記述するウィンドウ サイズクラスとの組み合わせに基づいて、この決定を行います。

@Composable
fun HomeRoute(
    // if the window size class is expanded
    isExpandedScreen: Boolean,
    // if the user is focused on the selected article
    isArticleOpen: Boolean,
    // ...
) {
    // ...
    if (isExpandedScreen) {
        HomeListWithArticleDetailsScreen(/* ... */)
    } else {
        // if we don't have room for both the list and article details,
        // show one of them based on the user's focus
        if (isArticleOpen) {
            ArticleScreen(/* ... */)
        } else {
            HomeListScreen(/* ... */)
        }
    }
}

このアプローチでは、アプリは HomeRoute 全体を別のデスティネーションに置き換えるナビゲーション操作(NavController)navigate() を呼び出す)を、このデスティネーション内のコンテンツのみに影響するナビゲーション操作(リストから記事を選択するなど)から明確に分離します。リスト画面と記事画面との間の遷移がユーザーに対するナビゲーション操作のように見えても、アプリが単一ペインのみを表示している場合、すべてのウィンドウ サイズに適用される共有状態を更新して、これらのイベントを処理することをおすすめします。

したがって、リスト内の記事をタップすると、ブール値フラグ isArticleOpen: が更新されます。

class HomeViewModel(/* ... */) {
    fun selectArticle(articleId: String) {
        viewModelState.update {
            it.copy(
                isArticleOpen = true,
                selectedArticleId = articleId
            )
        }
    }
}

@Composable
fun HomeRoute(
    isExpandedScreen: Boolean,
    isArticleOpen: Boolean,
    selectedArticleId: String,
    onSelectArticle: (String) -> Unit,
    // ...
) {
    // ...
    if (isExpandedScreen) {
        HomeListWithArticleDetailsScreen(
            selectedArticleId = selectedArticleId,
            onSelectArticle = onSelectArticle,
            // ...
        )
    } else {
        // if we don't have room for both the list and article details,
        // show one of them based on the user's focus
        if (isArticleOpen) {
            ArticleScreen(
                selectedArticleId = selectedArticleId,
                // ...
            )
        } else {
            HomeListScreen(
                onSelectArticle = onSelectArticle,
                // ...
            )
        }
    }
}

同様に、記事画面のみが表示される場合はカスタム BackHandler をインストールして、isArticleOpen を false に戻します。

class HomeViewModel(/* ... */) {
    fun onArticleBackPress() {
        viewModelState.update {
            it.copy(isArticleOpen = false)
        }
    }
}

@Composable
fun HomeRoute(
    isExpandedScreen: Boolean,
    isArticleOpen: Boolean,
    selectedArticleId: String,
    onSelectArticle: (String) -> Unit,
    onArticleBackPress: () -> Unit,
    // ...
) {
    // ...
    if (isExpandedScreen) {
        HomeListWithArticleDetailsScreen(/* ... */)
    } else {
        // if we don't have room for both the list and article details,
        // show one of them based on the user's focus
        if (isArticleOpen) {
            ArticleScreen(
                selectedArticleId = selectedArticleId,
                onUpPressed = onArticleBackPress,
                // ...
            )
            BackHandler {
                onArticleBackPress()
            }
        } else {
            HomeListScreen(/* ... */)
        }
    }
}

このレイヤ化によって、Compose アプリを設計する際の多くの重要なコンセプトが集約されます。画面を再利用可能にし、重要な状態をホイスティングできるようにすることで、画面全体を簡単に入れ替えることができます。ViewModel のアプリの状態と使用可能なサイズの情報を組み合わせることで、どの画面が表示されるかがシンプルなロジックで決定されます。最後に、単方向データフローを維持することで、アダプティブ UI は常にユーザーの状態を保持しながら利用可能なスペースを利用できます。

完全な実装については、GitHub の JetNews サンプルをご覧ください。

ユーザーの状態を保持する

アダプティブ UI の最も重要な考慮事項は、デバイスの回転時や折りたたみ時、またはアプリのウィンドウ サイズの変更時にユーザーの状態を保持することです。特に、これらのサイズ変更をすべて元に戻せる必要があります。

たとえば、ユーザーがアプリ内の画面を見てから、デバイスを回転させたとします。その回転を元に戻す(つまり、デバイスを回転させて元の位置に戻す)と、すべての状態が保持されたまま、開始したときとまったく同じ画面に戻る必要があります。回転させる前にコンテンツを部分的にスクロールしていた場合は、回転を元に戻した後に同じスクロール位置に戻る必要があります。

回転後にリストのスクロール位置のリストを保存する

画面の向きの変更とウィンドウのサイズ変更によって構成が変更されます。これにより、デフォルトでは Activity とコンポーザブルが再作成されます。状態は、rememberSaveable または ViewModel を使用したこれらの構成の変更を介して保存できます。詳しくは、状態と Jetpack Compose をご覧ください。このようなツールを使用していない場合、ユーザーの状態は失われます。

アダプティブ レイアウトは、さまざまな画面サイズでさまざまなコンテンツを表示する可能性があるため、追加の状態を保持する傾向があります。そのため、コンポーネントが表示されなくなった場合であっても、追加コンテンツについてユーザーの状態を保存しておくことも重要です。

もっと幅が広くないと、スクロールするコンテンツを表示できないとします。回転によって幅が狭くなりすぎて、スクロールするコンテンツを表示できない場合、そのコンテンツは非表示になります。ユーザーがデバイスを回転させて元の状態に戻すと、スクロールするコンテンツが再び表示されるようになり、元のスクロール位置が復元されます。

回転時に詳細のスクロール位置を保存する

Compose では、状態ホイスティングを使用してこれを実現できます。コンポーザブルの状態をコンポジション ツリーの上位にホイスティングすることで、コンポーザブルが表示されなくなった後も状態を保持できます。

JetNews では、状態を HomeRoute にホイスティングすることで、状態を保持して再利用しながら、表示される画面を変更できます。

@Composable
fun HomeRoute(
    // if the window size class is expanded
    isExpandedScreen: Boolean,
    // if the user is focused on the selected article
    isArticleOpen: Boolean,
    selectedArticleId: String,
    // ...
) {
    val homeListState = rememberHomeListState()
    val articleState = rememberSaveable(
        selectedArticleId,
        saver = ArticleState.Saver
    ) {
        ArticleState()
    }

    if (isExpandedScreen) {
        HomeListWithArticleDetailsScreen(
            homeListState = homeListState,
            articleState = articleState,
            // ...
        )
    } else {
        // if we don't have room for both the list and article details,
        // show one of them based on the user's focus
        if (isArticleOpen) {
            ArticleScreen(
                articleState = articleState,
                // ...
            )
        } else {
            HomeListScreen(
                homeListState = homeListState,
                // ...
            )
        }
    }
}

サイズ変更の副作用としてのナビゲーションを回避する

大きな画面が提供できる追加スペースを活用するアプリに画面を追加する場合、新たにデザインされたレイアウト用に新しいデスティネーションをアプリに追加したくなるかもしれません。

しかし、ユーザーが折りたたみ式デバイスの内側の画面にこの新しいレイアウトを表示しているとしたらどうなるでしょう。ユーザーがデバイスを折りたたんだ場合、外側の画面に新しいレイアウトを表示するための十分なスペースがない可能性があります。そのため、新しい画面サイズが小さすぎる場合は、別の場所に移動する必要が生じます。これにはいくつかの問題があります。

  • コンポジションの副作用としてナビゲーションを行うと、古いデスティネーションが一時的に表示されます。これは、ナビゲーションが発生する前に古いデスティネーションが表示される必要があるためです。
  • 可逆性を維持するため、展開時にナビゲーションを戻す必要もあります。
  • これらの変更時にユーザーの状態を維持するのは非常に困難です。ナビゲーションを行うことで、バックスタックがポップされるときに古い状態が失われる可能性があるためです。

その他の考慮事項としては、このような変更が行われている間、アプリがフォアグラウンドにない可能性もあります。アプリでもっと多くのスペースを必要とするレイアウトを表示していて、ユーザーがそのアプリをバックグラウンドに移行させる場合もあります。後でアプリに戻るときに、アプリが最後に再開されてから、向き、サイズ、物理画面がすべて変更されている可能性があります。

特定の画面サイズで一部のデスティネーションのみを表示する必要がある場合は、関連するデスティネーションを単一のルートにまとめ、上述の方法に従ってそのルートでさまざまな画面を表示することを検討してください。