Compose を使用してビューベースの Android アプリにアダプティブ レイアウトを追加する

1. 始める前に

Android デバイスはあらゆる形状とサイズで提供されているため、アプリのレイアウトをさまざまな画面サイズに合わせ、1 つの Android パッケージ(APK)または Android App Bundle(AAB)でさまざまなユーザーとデバイスに対応できるようにする必要があります。そのためには、特定の画面サイズやアスペクト比を前提とした固定ディメンションではなく、レスポンシブかつアダプティブなレイアウトでアプリを定義する必要があります。アダプティブ レイアウトは、使用可能な画面スペースに応じて変化します。

この Codelab では、アダプティブ UI を作成し、スポーツのリストと各スポーツの詳細情報を表示するアプリを、大画面デバイス向けに適応させる基本的な方法を説明します。このスポーツアプリは、ホーム、お気に入り、設定の 3 つの画面で構成されています。ホーム画面にはスポーツのリストが表示され、リストからスポーツを選択すると、ニュースのプレースホルダが表示されます。お気に入り画面と設定画面にもプレースホルダ テキストが表示されます。画面を切り替えるには、下部のナビゲーション メニューで関連する項目を選択します。

アプリでは、大画面で次のようなレイアウトの問題が発生します。

  • 縦向きで使用できない。
  • 大画面で空白スペースが多く表示される。
  • 大画面で下部のナビゲーション メニューが常に表示される。

次のようにアプリの適応性を改善します。

  • 横向きと縦向きの両方をサポートする。
  • 水平方向のスペースが十分にある場合は、スポーツのリストと各スポーツに関するニュースを並べて表示する。
  • マテリアル デザイン ガイドラインに沿ってナビゲーション コンポーネントを表示する。

このアプリは、複数のフラグメントを持つ単一アクティビティのアプリです。操作するファイルは次のとおりです。

  • AndroidManifest.xml ファイル。スポーツアプリに関するメタデータを提供します。
  • MainActivity.kt ファイル。activity_main.xml ファイルで生成されたコード、override アノテーション、幅のウィンドウ クラスサイズを表す enum クラス、アプリ ウィンドウの幅のウィンドウ クラスサイズを取得するメソッド定義が含まれています。下部のナビゲーション メニューは、アクティビティが作成されると初期化されます。
  • activity_main.xml ファイル。Main アクティビティのデフォルト レイアウトを定義します。
  • layout-sw600dp/activity_main.xml ファイル。Main アクティビティの代替レイアウトを定義します。代替レイアウトは、アプリ ウィンドウの幅が 600dp 値以上の場合に有効となります。その内容は、最初のデフォルト レイアウトと同じです。
  • SportsListFragment.kt ファイル。スポーツリストの実装とカスタムの「戻る」ナビゲーションが含まれます。
  • fragment_sports_list.xml ファイル。スポーツリストのレイアウトを定義します。
  • navigation_menu.xml ファイル。下部のナビゲーション メニュー項目を定義します。

コンパクト サイズのウィンドウではスポーツのリストが表示され、トップ ナビゲーション コンポーネントとしてナビゲーション バーが表示されます。 中程度のサイズのウィンドウでは、スポーツのリストと各スポーツに関するニュースが並んで表示されます。トップ ナビゲーション コンポーネントとしてナビゲーション レールが表示されます。 拡大サイズのウィンドウでは、ナビゲーション ドロワー、スポーツリスト、ニュースが表示されます。

図 1. このスポーツアプリは、1 つの APK または AAB でさまざまなウィンドウ サイズをサポートします。

前提条件

  • ビューベースの UI 開発に関する基本的な知識
  • ラムダ関数を含む Kotlin 構文の使用経験
  • Jetpack Compose の基本の Codelab を完了していること

学習内容

  • 構成の変更をサポートする方法。
  • コード変更の少ない代替レイアウトを追加する方法。
  • ウィンドウ サイズによって異なる動作をするリストと詳細の UI を実装する方法。

作成するアプリの概要

以下をサポートする Android アプリ。

  • デバイスの横向き
  • タブレット、パソコン、モバイル デバイス
  • 画面サイズに応じたリストと詳細の動作

必要なもの

2. 設定する

この Codelab のコードをダウンロードして、プロジェクトを設定します。

  1. コマンドラインから、この GitHub リポジトリのコードのクローンを作成します。
$ git clone https://github.com/android/add-adaptive-layouts
  1. Android Studio で AddingAdaptiveLayout プロジェクトを開きます。このプロジェクトには複数の git ブランチがあります。
  • main ブランチには、このプロジェクトのスターター コードが含まれています。このブランチに変更を加えて Codelab を完了します。
  • end ブランチには、この Codelab の解答があります。

3. トップ ナビゲーション コンポーネントを Compose に移行する

このスポーツアプリでは、下部のナビゲーション メニューをトップ ナビゲーション コンポーネントとして使用します。このナビゲーション コンポーネントは、BottomNavigationView クラスで実装されます。このセクションでは、トップ ナビゲーション コンポーネントを Compose に移行します。

関連付けられているメニュー リソースの内容をシールクラスとして表す

  1. navigation_menu.xml ファイルの内容を表すシールされた MenuItem クラスを作成し、iconId パラメータ、labelId パラメータ、destinationId パラメータを渡します。
  2. デスティネーションに対応するサブクラスとして、Home オブジェクト、Favorite オブジェクト、Settings オブジェクトを追加します。
sealed class MenuItem(
    // Resource ID of the icon for the menu item
    @DrawableRes val iconId: Int,
    // Resource ID of the label text for the menu item
    @StringRes val labelId: Int,
    // ID of a destination to navigate users
    @IdRes val destinationId: Int
) {

    object Home: MenuItem(
        R.drawable.ic_baseline_home_24,
        R.string.home,
        R.id.SportsListFragment
    )

    object Favorites: MenuItem(
        R.drawable.ic_baseline_favorite_24,
        R.string.favorites,
        R.id.FavoritesFragment
    )

    object Settings: MenuItem(
        R.drawable.ic_baseline_settings_24,
        R.string.settings,
        R.id.SettingsFragment
    )
}

下部のナビゲーション メニューをコンポーズ可能な関数として実装する

  1. List<MenuItem> 値に設定された menuItemsオブジェクト、Modifier = Modifier 値に設定された modifier オブジェクト、(MenuItem) -> Unit = {} ラムダ関数に設定された onMenuSelected オブジェクトの 3 つのパラメータを受け取るコンポーザブル可能な BottomNavigationBar 関数を定義します。
  2. コンポーザブル可能な BottomNavigationBar 関数の本文で、modifier パラメータを受け取る NavigationBar() 関数を呼び出します。
  3. NavigationBar() 関数に渡されたラムダ関数で、menuItems パラメータで forEach() メソッドを呼び出してから、foreach() メソッド呼び出しに設定されたラムダ関数で NavigationBarItem() 関数を呼び出します。
  4. NavigationBarItem() 関数に、false 値に設定された selected パラメータ、MenuItem パラメータを持つ onMenuSelected 関数を含むラムダ関数に設定された onClick パラメータ、painter = painterResource(id = menuItem.iconId) パラメータと contentDescription = null パラメータを受け取る Icon 関数を含むラムダ関数に設定された icon パラメータ、(text = stringResource(id = menuItem.labelId) パラメータを受け取る Text 関数を含むラムダ関数に設定された label パラメータを渡します。
@Composable
fun BottomNavigationBar(
    menuItems: List<MenuItem>,
    modifier: Modifier = Modifier,
    onMenuSelected: (MenuItem) -> Unit = {}
) {

    NavigationBar(modifier = modifier) {
        menuItems.forEach { menuItem ->
            NavigationBarItem(
                selected = false,
                onClick = { onMenuSelected(menuItem) },
                icon = {
                    Icon(
                        painter = painterResource(id = menuItem.iconId),
                        contentDescription = null)
                },
                label = { Text(text = stringResource(id = menuItem.labelId))}
            )
        }
    }
}

BottomNavigationView 要素をレイアウト リソース ファイル内の ComposeView 要素に置き換える

  • activity_main.xml ファイルで、BottomNavigationView 要素を ComposeView 要素に置き換えます。これにより、Compose で、ビューベースの UI レイアウトにボトム ナビゲーション コンポーネントが埋め込まれます。

activity_main.xml

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior">

        <androidx.fragment.app.FragmentContainerView
            android:id="@+id/nav_host_fragment_content_main"
            android:name="androidx.navigation.fragment.NavHostFragment"
            android:layout_width="0dp"
            android:layout_height="0dp"
            app:defaultNavHost="true"
            app:layout_constraintBottom_toTopOf="@id/top_navigation"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:navGraph="@navigation/nav_graph" />

        <androidx.compose.ui.platform.ComposeView
            android:id="@+id/navigation"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:menu="@menu/top_navigation" />
    </androidx.constraintlayout.widget.ConstraintLayout>

Compose ベースの下部のナビゲーション メニューをビューベースの UI レイアウトと統合する

  1. MainActivity.kt ファイルで、MenuItem オブジェクトのリストに設定された navigationMenuItems 変数を定義します。MenuItems オブジェクトは、下部のナビゲーション メニューにリスト順に表示されます。
  2. BottomNavigationBar() 関数を呼び出して、下部のナビゲーション メニューを ComposeView オブジェクトに埋め込みます。
  3. BottomNavigationBar 関数に渡されたコールバック関数で、ユーザーが選択した項目に関連付けられたデスティネーションに移動します。

MainActivity.kt

val navigationMenuItems = listOf(
    MenuItem.Home,
    MenuItem.Favorites,
    MenuItem.Settings
)

binding.navigation.setContent {
    MaterialTheme {
        BottomNavigationBar(menuItems = navigationMenuItems){ menuItem ->
            navController.navigate(screen.destinationId)
        }
    }
}

4. 横向きをサポートする

アプリが大画面デバイスをサポートしている場合は、横向きと縦向きをサポートすることが求められます。現時点では、アプリには MainActivity という 1 つのアクティビティのみがあります。

デバイス上のアクティビティの表示の向きは、portrait 値に設定された android:screenOrientation 属性を含む AndroidManifest.xml ファイルで設定されます。

アプリが横向きをサポートするようにします。

  1. android:screenOrientation 属性を fullUser 値に設定します。この設定により、ユーザーは画面の向きをロックできます。画面の向きは、デバイスの方向センサーによって 4 つの向きのいずれかに決定されます。

AndroidManifest.xml

<activity
    android:name=".MainActivity"
    android:exported="true"
    android:screenOrientation="fullUser">

AndroidManifest.xml ファイルのアクティビティ要素の android:screenOrientation 属性に fullUser 値を設定することで、横向きがサポートされます。

図 2. AndroidManifest.xml ファイルを更新すると、アプリは横向きで実行されます。

5. ウィンドウ サイズクラス

ウィンドウ サイズクラスは、アプリで使用できる未加工のウィンドウ サイズを使って、事前定義されたサイズクラス(コンパクト、中程度、拡大)にウィンドウ サイズを分類するためのブレークポイント値です。このサイズクラスは、アダプティブ レイアウトを設計、開発、テストする場合に使用します。

利用可能な幅と高さは個別に分割されるため、アプリには常に、幅のウィンドウ サイズクラスと高さのウィンドウ サイズクラスという、2 つのウィンドウ サイズクラスが関連付けられています。

3 つの幅のウィンドウ サイズクラスの間に 2 つのブレークポイントがあります。600dp 値はコンパクトと中程度の間にあるブレークポイントで、840dp 値は中程度の幅と拡大幅のウィンドウ サイズクラスの間にあるブレークポイントです。

図 3. 幅のウィンドウ サイズクラスと、それに関連するブレークポイント

3 つの高さのウィンドウ サイズクラスの間に 2 つのブレークポイントがあります。480dp 値はコンパクトと中程度の高さのウィンドウ サイズクラスの間にあるブレークポイントで、900dp 値は中程度と拡大の高さのウィンドウ クラスの間にあるブレークポイントです。

図 4. 高さのウィンドウ サイズクラスと、それに関連するブレークポイント

ウィンドウ サイズクラスは、アプリの現在のウィンドウ サイズを表します。つまり、物理デバイスのサイズでウィンドウ サイズクラスを判断することはできません。アプリが同じデバイスで実行されている場合でも、関連するウィンドウ サイズクラスは、アプリを分割画面モードで実行する場合など、構成によって異なります。その結果、次の 2 点が重要となります。

  • 物理デバイスで、特定のウィンドウ サイズクラスが保証されるわけではありません。
  • ウィンドウ サイズクラスは、アプリの全期間を通じて変わる可能性があります。

アプリにアダプティブ レイアウトを追加したら、すべてのウィンドウ サイズ(特にコンパクト、中程度、拡大のウィンドウ サイズクラス)でアプリをテストします。ウィンドウ サイズクラスごとにテストが必要ですが、多くの場合、十分ではありません。UI を正しくスケーリングできるように、アプリをさまざまなウィンドウ サイズでテストすることが重要です。詳しくは、大画面のレイアウト大画面のアプリの品質をご覧ください。

6. 大画面でリストペインと詳細ペインを並べて配置する

リストと詳細の UI では、現在の幅のウィンドウ サイズクラスによって異なる動作が必要になる場合があります。中程度の幅または拡大幅のウィンドウ サイズクラスがアプリに関連付けられている場合、アプリには、リストペインと詳細ペインを並べて表示するのに十分なスペースがあり、ユーザーは画面遷移なしで項目のリストと選択した項目の詳細情報を確認できます。しかし、小さな画面では表示が過密になる場合があるため、1 つずつペインを表示することをおすすめします(最初はリストペインを表示)。ユーザーがリスト内の項目をタップすると、詳細ペインに選択した項目の詳細が表示されます。SlidingPaneLayout クラスは、これら 2 つのユーザー エクスペリエンスのどちらが現在のウィンドウ サイズに適しているかを判断するロジックを管理します。

リストペインのレイアウトを設定する

SlidingPaneLayout クラスはビューベースの UI コンポーネントです。リストペインのレイアウト リソース ファイル(この Codelab では fragment_sports_list.xml ファイル)を変更します。

SlidingPaneLayout クラスは 2 つの子要素を受け取ります。各子要素の幅と重みの属性は、ウィンドウが両方のビューを並べて表示するのに十分な大きさであるかどうかを判断するための SlidingPaneLayout クラスの重要な要素です。ウィンドウが十分な大きさでない場合は、全画面表示のリストが全画面表示の詳細 UI に置き換えられます。ウィンドウ サイズがペインを並べて表示するための最小要件よりも大きい場合、2 つのペインのサイズを比率で指定するために weight 値が参照されます。

SlidingPaneLayout クラスが fragment_sports_list.xml ファイルに適用されました。リストペインの幅は 1280dp になるように構成されています。そのため、リストペインと詳細が並んで表示されません。

画面の幅が 580dp より大きい場合、ペインを並べて表示します。

  • RecyclerView280dp の幅に設定します。

fragment_sports_list.xml

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

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="280dp"
        android:layout_height="match_parent"
        android:layout_weight="1"
        android:clipToPadding="false"
        android:padding="8dp"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />

    <androidx.fragment.app.FragmentContainerView
        android:layout_height="match_parent"
        android:layout_width="300dp"
        android:layout_weight="1"
        android:id="@+id/detail_container"
        android:name="com.example.android.sports.NewsDetailsFragment"/>
</androidx.slidingpanelayout.widget.SlidingPaneLayout>

sports_list_fragment.xml ファイルを更新すると、スポーツリストと選択したスポーツに関するニュースが並んで表示されます。

図 5. レイアウト リソース ファイルを更新すると、リストペインと詳細ペインが並んで表示されます。

詳細ペインを入れ替える

中程度の幅または拡大幅のウィンドウ サイズクラスをアプリに関連付けると、リストペインと詳細ペインが並んで表示されるようになりました。ただし、ユーザーがリストペインから項目を選択すると、画面は詳細ペインに完全に遷移します。

アプリは画面遷移を実行してスポーツに関するニュースのみを表示しますが、アプリではスポーツリストとニュースを並べて表示し続けることが想定されています。

図 6. リストからスポーツを選択すると、画面が詳細ペインに遷移します。

この問題は、ユーザーがリストペインから項目を選択したときにトリガーされるナビゲーションが原因で発生します。関連するコードは SportsListFragment.kt ファイルにあります。

SportsListFragment.kt

val adapter = SportsAdapter {
    sportsViewModel.updateCurrentSport(it)
    // Navigate to the details pane.
    val action =
       SportsListFragmentDirections.actionSportsListFragmentToNewsFragment()
    this.findNavController().navigate(action)
}

リストペインと詳細ペインを並べて表示するための十分なスペースがない場合にのみ、画面が詳細ペインに完全に遷移するようにします。

  • adapter 関数変数に、SlidingPaneLayout クラスの isSlidable 属性が true であり、SlidingPaneLayout クラスの isOpen 属性が false であるかどうかをチェックする if ステートメントを追加します。

SportsListFragment.kt

val adapter = SportsAdapter {
    sportsViewModel.updateCurrentSport(it)
    if(slidingPaneLayout.isSlidable && !slidingPaneLayout.isOpen){
        // Navigate to the details pane.
        val action =
           SportsListFragmentDirections.actionSportsListFragmentToNewsFragment()
        this.findNavController().navigate(action)
    }
}

7. 幅のウィンドウ サイズクラスに基づいて適切なナビゲーション コンポーネントを選択する

マテリアル デザインでは、アプリは適応的にコンポーネントを選択する必要があります。このセクションでは、現在の幅のウィンドウ サイズクラスに基づいて、上部のナビゲーション バーのナビゲーション コンポーネントを選択します。次の表に、各ウィンドウ サイズクラスで想定されるナビゲーション コンポーネントを示します。

幅のウィンドウ サイズクラス

ナビゲーション コンポーネント

コンパクト

ボトム ナビゲーション

中程度

ナビゲーション レール

拡大

固定的なナビゲーション ドロワー

ナビゲーション レールを実装する

  1. List<MenuItem> 値に設定された menuItems オブジェクト、Modifier 値に設定された modifier オブジェクト、onMenuSelected ラムダ関数の 3 つのパラメータを受け取るコンポーズ可能な NavRail() 関数を作成します。
  2. 関数の本文で、modifier オブジェクトをパラメータとして受け取る NavigationRail() 関数を呼び出します。
  3. BottomNavigationBar() 関数内の NavigationBarItem 関数の場合と同様に、menuItems オブジェクト内の各 MenuItem オブジェクトに対して NavigationRailItem() 関数を呼び出します。

固定的なナビゲーション ドロワーを実装する

  1. List<MenuItem> 値に設定された menuItems オブジェクト、Modifier 値に設定された modifier オブジェクト、onMenuSelected ラムダ関数の 3 つのパラメータを受け取るコンポーズ可能な NavigationDrawer() 関数を作成します。
  2. 関数の本文で、modifier オブジェクトをパラメータとして受け取る Column() 関数を呼び出します。
  3. menuItems オブジェクト内の各 MenuItem オブジェクトに対して Row() 関数を呼び出します。
  4. BottomNavigationBar() 関数内の NavigationBarItem 関数の場合と同様に、Row() 関数の本文で、icon ラベルと text ラベルを追加します。

幅のウィンドウ サイズクラスに基づいて適切なナビゲーション コンポーネントを選択する

  1. 現在の幅のウィンドウ サイズクラスを取得するには、ComposeView オブジェクトの setContent() メソッドに渡されたコンポーズ可能な関数内で rememberWidthSizeClass() 関数を呼び出します。
  2. 条件付きブランチを作成して、取得した幅のウィンドウ サイズクラスに基づいてナビゲーション コンポーネントを選択し、選択したコンポーネントを呼び出します。
  3. Modifier オブジェクトを NavigationDrawer 関数に渡して、その幅を 256dp 値として指定します。

ActivityMain.kt

binding.navigation.setContent {
    MaterialTheme {
        when(rememberWidthSizeClass()){
            WidthSizeClass.COMPACT ->
                BottomNavigationBar(menuItems = navigationMenuItems){ menuItem ->
                    navController.navigate(screen.destinationId)
                }
            WidthSizeClass.MEDIUM ->
                NavRail(menuItems = navigationMenuItems){ menuItem ->
                    navController.navigate(screen.destinationId)
                }
            WidthSizeClass.EXPANDED ->
                NavigationDrawer(
                    menuItems = navigationMenuItems,
                    modifier = Modifier.width(256.dp)
                ) { menuItem ->
                    navController.navigate(screen.destinationId)
                }
        }

    }
}

ナビゲーション コンポーネントを適切な位置に配置する

これで、アプリは現在のウィンドウ サイズクラスに基づいて適切なナビゲーション コンポーネントを選択できるようになりましたが、選択したコンポーネントは期待どおりに配置できません。これは、ComposeView 要素が FragmentViewContainer 要素の下に配置されているためです。

MainActivity クラスの代替レイアウト リソースを更新します。

  1. layout-sw600dp/activity_main.xml ファイルを開きます。
  2. ComposeView 要素と FragmentContainer 要素を水平方向に配置するように制約を更新します。

layout-sw600dp/activity_main.xml

  <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior">

        <androidx.fragment.app.FragmentContainerView
            android:id="@+id/nav_host_fragment_content_main"
            android:name="androidx.navigation.fragment.NavHostFragment"
            android:layout_width="0dp"
            android:layout_height="0dp"
            app:defaultNavHost="true"
            app:layout_constraintLeft_toRightOf="@+id/top_navigation"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:navGraph="@navigation/nav_graph" />

        <androidx.compose.ui.platform.ComposeView
            android:id="@+id/top_navigation"
            android:layout_width="wrap_content"
            android:layout_height="0dp"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:menu="@menu/top_navigation" />
    </androidx.constraintlayout.widget.ConstraintLayout>

変更後、アプリは、関連付けられた幅のウィンドウ サイズクラスに基づいて適切なナビゲーション コンポーネントを選択します。1 つ目のスクリーンショットは、中程度の幅のウィンドウ サイズクラスの画面を示しています。2 つ目のスクリーンショットは、拡大幅のウィンドウ サイズクラスの画面を示しています。

スポーツアプリが中程度の幅のウィンドウ サイズクラスに関連付けられている場合、ナビゲーション レール、スポーツリスト、ニュースが表示されます。 スポーツアプリが拡大幅のウィンドウ サイズクラスに関連付けられている場合、ナビゲーション ドロワー、スポーツリスト、ニュースがホーム画面に表示されます。

図 7. 中程度の幅および拡大幅のウィンドウ クラスの画面

8. 完了

これで、この Codelab は終了です。Compose を使用してビューベースの Android アプリにアダプティブ レイアウトを追加する方法を学習しました。その過程で、SlidingPaneLayout クラス、ウィンドウ サイズクラス、幅のウィンドウ サイズクラスに基づくナビゲーション コンポーネントの選択について学習しました。

詳細