アダプティブ レイアウトを作成する

アプリの UI は、さまざまな画面サイズ、向き、フォーム ファクタに対応するためにレスポンシブでなければなりません。アダプティブ レイアウトは、使用可能な画面スペースに応じて変化します。この変化は、スペースを埋めるための単純なレイアウト調整から、追加の空間を活用するためのレイアウトの完全な変更まで、多岐にわたります。

宣言型 UI ツールキットである Jetpack Compose は、さまざまなサイズに合わせて異なる方法でコンテンツをレンダリングするように自動的に調整されるレイアウトを設計および実装するのに適しています。このドキュメントでは、Compose を使用して UI をレスポンシブにする方法に関するガイドラインをいくつか示します。

画面レベルのコンポーザブルの大規模なレイアウト変更を明示的にする

Compose を使用してアプリ全体をレイアウトする場合、アプリレベルと画面レベルのコンポーザブルは、レンダリング用としてアプリに与えられるすべてのスペースを占有します。設計のこのレベルでは、大画面を利用できるように画面の全体的なレイアウトを変更することが合理的です。

レイアウトに関する決定を行う際は、物理的なハードウェア値を使用しないでください。固定された具体的な値(デバイスはタブレットかどうか、物理画面に特定のアスペクト比があるかなど)に基づいて決定したくなることもあるでしょうが、こうした問いに対する答えは、UI が操作できるスペースを決定するうえで必ずしも有用ではありません。

デバイスのさまざまなフォーム ファクタ(スマートフォン、折りたたみ式デバイス、タブレット、ノートパソコン)を示す図

タブレットでは、アプリがマルチ ウィンドウ モードで実行されている可能性があります。これは、アプリが別のアプリで画面を分割している可能性があるということです。ChromeOS では、サイズ変更可能なウィンドウ内にアプリが表示される場合があります。折りたたみ式デバイスのように、複数の物理画面が存在する場合もあります。上記のいずれの場合も、物理画面サイズはコンテンツの表示方法の決定とは無関係です。

そうした決定は、Jetpack の WindowManager ライブラリによって提供される現在のウィンドウ指標など、アプリに割り当てられる画面の実際の領域に基づいて行う必要があります。Compose アプリで WindowManager を使用する方法については、JetNews サンプルをご確認ください。

このアプローチを採用すると、アプリが上記のすべてのシナリオで適切に動作するため、アプリの柔軟性が向上します。利用可能な画面スペースにレイアウトを適応させることで、ChromeOS などのプラットフォームや、タブレットや折りたたみ式などのフォーム ファクタをサポートするための特別な処理の量も削減できます。

アプリが使用できる関連スペースを確認したら、ウィンドウ サイズクラスで説明しているように、未加工のサイズを意味のあるサイズクラスに変換すると便利です。 それにより、サイズが標準サイズのバケットにグループ化されます。このバケットは、ほとんどの独自のケースでアプリを最適化できる柔軟性とシンプルさのバランスを取れるように設計されたブレークポイントです。これらのサイズクラスはアプリのウィンドウ全体を参照するので、画面レイアウト全体に影響するレイアウトを決定する際に使用してください。これらのサイズクラスは状態として渡すことができます。また、追加のロジックを実行して、ネストされたコンポーザブルに渡す派生状態を作成することもできます。

class MainActivity : ComponentActivity() {
    @OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            val windowSizeClass = calculateWindowSizeClass(this)
            MyApp(windowSizeClass)
        }
    }
}
@Composable
fun MyApp(windowSizeClass: WindowSizeClass) {
    // Perform logic on the size class to decide whether to show
    // the top app bar.
    val showTopAppBar = windowSizeClass.heightSizeClass != WindowHeightSizeClass.Compact

    // MyScreen knows nothing about window sizes, and performs logic
    // based on a Boolean flag.
    MyScreen(
        showTopAppBar = showTopAppBar,
        /* ... */
    )
}

このような階層化されたアプローチでは、画面サイズのロジックを単一の場所にとどめておくことができます。アプリのさまざまな場所にロジックを分散させてそれらの同期を維持する必要はありません。この単一の場所で状態が生成され、他のアプリの状態と同様に、その状態を他のコンポーザブルに明示的に渡すことができます。状態を明示的に渡すことで、個別のコンポーザブルが簡素化されます。個別のコンポーザブルは、他のデータとともにサイズクラスまたは指定された構成を受け取る通常のコンポーズ可能な関数にすぎないからです。

柔軟なネストされたコンポーザブルは再利用が可能

コンポーザブルは、さまざまな場所に配置できる場合、より再利用しやすくなります。コンポーザブルが常に特定のサイズで特定の場所に配置されることを想定している場合は、別の場所で再利用することや、異なる使用可能なスペースの量で再利用することが困難になります。これは、再利用可能な個別のコンポーザブルが「グローバル」なサイズ情報に暗黙的に依存しないようにする必要があることも意味します。

例を考えてみましょう。リスト詳細レイアウトを実装するネストされたコンポーザブルが、1 つのペインまたは横に並んだ 2 つのペインを表示するとします。

横に並んだ 2 つのペインを表示するアプリのスクリーンショット

図 1. 一般的なリスト/詳細レイアウトを表示しているアプリのスクリーンショット。1 はリスト領域、2 は詳細領域です。

この決定をアプリの全体的なレイアウトに含めたいので、前述のように、画面レベルのコンポーザブルから決定を渡します。

@Composable
fun AdaptivePane(
    showOnePane: Boolean,
    /* ... */
) {
    if (showOnePane) {
        OnePane(/* ... */)
    } else {
        TwoPane(/* ... */)
    }
}

代わりに、使用可能なスペースに応じて、コンポーザブルが自身のレイアウトを個別に変更するようにしたい場合はどうでしょうか。たとえば、スペースに余裕があるときに追加の詳細を表示するカードを使用したい場合です。使用可能なサイズに応じてなんらかのロジックを実行する必要がありますが、具体的にはどのサイズを使用すればよいでしょうか。

2 つの異なるカードの例: アイコンとタイトルのみを表示する幅の狭いカードと、アイコン、タイトル、簡単な説明を表示する幅の広いカード

前述のように、デバイスの実際の画面のサイズを使用するべきではありません。その値は、複数の画面がある場合も、アプリが全画面表示でない場合も不適切です。

このコンポーザブルは画面レベルのコンポーザブルではないので、再利用可能性を最大化するために、現在のウィンドウ指標も直接使用するべきではありません。コンポーネントがパディング付きで配置されている場合(インセット用など)、またはナビゲーション レールやアプリバーなどのコンポーネントが存在する場合は、コンポーザブルが使用できるスペースの量が、アプリが使用できる全体的なスペースと大きく異なることがあります。

したがって、コンポーザブルが自身のレンダリング用として実際に与えられる幅を使用するべきです。この幅を取得するには、次の 2 つの方法があります。

コンテンツを表示する場所または方法を変更したい場合は、修飾子のコレクションまたはカスタム レイアウトを使用して、レイアウトをレスポンシブにすることができます。これは、すべての使用可能なスペースを子で埋めるか、十分な空間がある場合に複数の列に複数の子をレイアウトする単純な方法で実現できます。

表示する内容を変更したい場合は、代わりにより強力な方法である BoxWithConstraints を使用できます。このコンポーザブルは、使用可能なスペースに応じて異なるコンポーザブルを呼び出すために使用できる測定制約を提供します。ただし、この方法はある程度のコストがかかります。BoxWithConstraints はこれらの制約が認識されるレイアウト フェーズまでコンポジションを遅延するので、レイアウト時に、より多くの処理が必要になるからです。

@Composable
fun Card(/* ... */) {
    BoxWithConstraints {
        if (maxWidth < 400.dp) {
            Column {
                Image(/* ... */)
                Title(/* ... */)
            }
        } else {
            Row {
                Column {
                    Title(/* ... */)
                    Description(/* ... */)
                }
                Image(/* ... */)
            }
        }
    }
}

さまざまなサイズですべてのデータを使用できるようにする

追加の画面スペースを利用する場合、大きな画面では、小さな画面よりも多くのコンテンツをユーザーに表示するための空間が必要になることがあります。そのように動作するコンポーザブルを実装する際は、効率化を図るために、現在のサイズの副作用としてデータを読み込みたくなるかもしれません。

しかし、これは単方向データフローの原則に反します。単方向データフローでは、データをホイスティングして単純にコンポーザブルに提供することにより、適切なレンダリングが可能になります。データの一部が必ずしも使用されない場合でも、任意のサイズで表示する必要がある情報をコンポーザブルが常に保持できるように、コンポーザブルに十分なデータを提供する必要があります。

@Composable
fun Card(
    imageUrl: String,
    title: String,
    description: String
) {
    BoxWithConstraints {
        if (maxWidth < 400.dp) {
            Column {
                Image(imageUrl)
                Title(title)
            }
        } else {
            Row {
                Column {
                    Title(title)
                    Description(description)
                }
                Image(imageUrl)
            }
        }
    }
}

Card の例では、常に descriptionCard に渡していることに注意してください。description は、それを表示できる幅がある場合にのみ使用されますが、Card は、使用可能な幅と無関係に常にそれを必要とします。

常にデータを渡すことで、アダプティブ レイアウトはよりステートフルでなくなり、よりシンプルになります。また、サイズの切り替え(ウィンドウ サイズの変更、向きの変更、デバイスの折りたたみと展開によって発生)の際に副作用がトリガーされることを回避できます。

また、この原則により、レイアウトの変更後も状態を保持できます。すべてのサイズで使用されるわけではない情報をホイスティングすることで、レイアウト サイズの変更時もユーザーの状態を保持できます。たとえば、showMore ブール値フラグをホイスティングすると、サイズ変更に応じてレイアウトが説明の表示と非表示を切り替える際に、ユーザーの状態が保持されます。

@Composable
fun Card(
    imageUrl: String,
    title: String,
    description: String
) {
    var showMore by remember { mutableStateOf(false) }

    BoxWithConstraints {
        if (maxWidth < 400.dp) {
            Column {
                Image(imageUrl)
                Title(title)
            }
        } else {
            Row {
                Column {
                    Title(title)
                    Description(
                        description = description,
                        showMore = showMore,
                        onShowMoreToggled = { newValue ->
                            showMore = newValue
                        }
                    )
                }
                Image(imageUrl)
            }
        }
    }
}

詳細

Compose のカスタム レイアウトについて詳しくは、以下の参考情報をご覧ください。

サンプルアプリ

  • 大画面正規化レイアウトは、大画面デバイスで最適なユーザー エクスペリエンスを提供するための、実績のある設計パターンのリポジトリです。
  • JetNews では、利用可能なスペースを活用するように UI を適応させるアプリの設計方法を紹介しています。
  • Reply は、モバイル、タブレット、折りたたみ式デバイスをサポートするアダプティブ サンプルです。
  • Now in Android は、アダプティブ レイアウトを使用してさまざまな画面サイズをサポートするアプリです。

動画