Android エコシステムは広大で、ユーザーはスマートフォン、タブレット、折りたたみ式デバイス、デスクトップ環境をシームレスに切り替えています。Compose First になった今、このセッションでは、サイズ変更可能なウィンドウとさまざまなアスペクト比に対応するアプリを設計する方法について説明します。従来のレイアウトの前提が崩れ、UI が引き伸ばされ、状態が失われる可能性があります。
Jetpack Compose、Navigation 3、WindowManager の最新機能を使用して、コンパクトな画面からデュアルペイン ディスプレイまで自然にスケーリングする動的なレイアウトを構築する方法を学びます。Gemini と、アダプティブ スキルなどのスキルを使用して開発ワークフローを加速し、大画面向けにレガシー コードベースを自動的にリファクタリングする戦略を学びましょう。
MediaQuery API は、ウィンドウ サイズや折りたたみ姿勢などのアクティブな UI 機能を宣言的に追跡します。複数のレガシー プラットフォーム マネージャーを Compose 状態に結合する代わりに、この API を使用します。Grid プリミティブと FlexBox プリミティブは、アダプティブ レイアウトのビルディング ブロックを提供します。Grid は、単一のコンポーズ可能な関数内でパラメータ化された多次元配置を可能にし、FlexBox は 1 次元のスペース配分とラッピングを提供します。文字起こし
Francesco Romano
こんにちは。
本日は、Jetpack Compose を使用してアプリをあらゆる画面にスケーリングする方法について、技術的な観点から詳しく説明します。
あらゆる画面にスケーリングする理由
Android エコシステムがさまざまなフォーム ファクタに拡大しているため。
APK は、スマートフォン、折りたたみ式デバイス、タブレット、Chromebook、Googlebook、テレビ、車載インフォテインメント システム、XR デバイスなどで実行できます。
まず、ユーザーと同じ目線に立つことです。
2 つ目は、Android プラットフォームのアダプティブ アプリへの移行は一時的なものではないということです。
電話だけの世界に戻るわけではありません。
ここに簡単なタイムラインがあります。
2022 年に、Google はその基盤を築き始めました。
Android 12L、Jetpack WindowManager の最初のリリース、ウィンドウ サイズクラスなどの API、アダプティブ アプリの品質に関するガイドラインの最初のバージョンを公開しました。
また、Google Play を更新して、評価がデバイスタイプ別に分割されるようにし、レスポンシブなアプリの品質に関するガイドラインをランキングの決定に組み込むようにしました。
Android 15 では、デスクトップ ウィンドウが AOSP に追加され、Compose Material 3 Adaptive が安定版になりました。
Android 16(SDK 36)以降では、大画面での画面の向きのロック、アスペクト比のロック、サイズ変更フラグがシステムによって無視されるようになります。
接続ディスプレイのリリースに伴い、APK は事実上あらゆるウィンドウ サイズで実行できるようになりました。
また、SDK 36 では、向きとアスペクト比のオーバーライドをオプトアウトするための一時的なマニフェスト フラグが 1 つ提供されました。
Android 17 では、このオプトアウトは廃止されます。
そのため、アダプティブなアプリを構築することは、プラットフォームの基本的な要件です。
過去 10 年間、Google はエコシステム全体のチームと緊密に連携し、アダプティブなアプリの構築が難しく感じられる理由を直接学びました。
状態の損失、ウィンドウ API の断片化、レイアウトを切り替えるタイミングに関する不明確なシグナル、コードベースに深く組み込まれた古い前提などがあります。
そこで、この 10 年間にデベロッパーが直面した課題から学んだことをすべて、2 つの場所に直接組み込みました。1 つ目は UI ツールキット自体です。
今回は、Jetpack Compose の主要な新機能であるメディアクエリ、グリッド、FlexBox、Navigation 3 について説明します。
また、Android Studio の Gemini と Android CLI の公式 Android スキルを通じて、コードを一緒に記述するエージェント型ツールでも利用できます。
たとえば、今年の Google I/O でリリースされた Compose のアダプティブ スキルについて説明します。
すでに、スマートフォンのレイアウトをより大きなウィンドウ サイズに移行できるようになっていました。
また、状態損失を修正したり、テーブルトップなどの折りたたみ式デバイス独自の機能を実装したり、キーボード ショートカット、カスタム カーソル、ツールチップなどを検出して実装することで入力サポートを改善したりすることもできます。
では、すべての基盤となる Jetpack Compose から始めましょう。
Jetpack Compose は 2021 年に安定版がリリースされたため、今年で 5 周年を迎えます。
この 5 年間で、数千もの本番環境アプリを通じて、Compose を使用するとアダプティブなアプリをより簡単かつ確実に構築できることが実証されてきました。
Android 開発は正式に Compose ファーストになりました。
つまり、新しい最新のアダプティブ レイアウト、スタイル設定、ナビゲーション コンポーネントはすべて Compose 用に構築されています。
システム アーキテクチャの観点から見ると、アダプティブ アプリ向け Compose が従来のビューシステムよりも優れている最大の理由は、状態と UI の相互作用にあります。
これを説明するために、ビューシステムと簡単に比較してみましょう。
ビューシステムの仕組みを考えてみましょう。
UI は、静的 XML ファイルから一度インフレートした可変オブジェクトのツリーでした。
さまざまなリソース修飾子がありましたが、まずは、さまざまなリソース修飾子にわたる重複という大きな問題から始めましょう。
XML でアダプティブ UI を構築する場合、レイアウト ファイル内に条件付きロジックはありません。
代わりに、layout、layout-sw600dp、layout-land などのリソース フォルダ全体で階層全体を複製します。
右側の EditText の例をご覧ください。
<LinearLayout android:orientation="vertical">
<EditText
android:id="@+id/text_msg"
android:inputType="textCapCharacters"
android:maxLength="12" />
</LinearLayout><ConstraintLayout>
<TextInputEditText
android:id="@+id/text_input"
android:inputType="text" />
</ConstraintLayout>これは、テキスト入力を処理するまったく同じ論理コンポーネントであり、さまざまな XML ファイルに分散しています。
スマートフォンのレイアウトで inputType や maxLength などの属性を更新しても、タブレットのレイアウトでも更新するようコンパイラが強制することはありません。
また、コンポーネントが異なるファイルに分離されているため、時間の経過とともに構造的なドリフトがほぼ確実に発生します。
この重複は、構成変更時の状態の損失という別の問題にも関連しています。
View システムでは、UI コンポーネントはステートフルです。
EditText は、内部の編集可能なバッファ、カスタム カーソル位置、フォーカス状態、一部の状態を所有します。
そのため、ユーザーがデバイスを広げたり、ウィンドウのサイズを特定のしきい値を超えて変更したりすると、Android はビュー階層全体を破棄し、新しい XML ファイルをゼロからインフレートします。
ユーザー入力を保存するために、フレームワークは onSaveInstanceState を利用します。これは、そのビューの ID にリンクされた状態をパックします。
しかし、その場合、3 つの障害モードが発生する可能性があります。
まず、別の XML で誰かが誤って ID を text_msg ではなく text_input と名付けた場合、状態の復元は警告なしでユーザーの入力テキストを破棄します。
2 つ目は、onRestoreInstanceState が onViewCreated の後に実行されるため、ViewModel オブザーバーと頻繁に競合し、コールバック ループが発生する可能性があることです。
3 つ目は、修飾子間でウィジェット タイプを入れ替える場合です。たとえば、コンパクトな画面ではスピナー、幅の広い画面では RadioGroup を使用するとします。これらが同じ ID を共有している場合、実行時に ClassCastException でクラッシュします。
一方、Jetpack Compose では、UI は一度構築するオブジェクトではありません。
UI は状態の純粋関数です。
そして、その変更は構成の変更そのものを完全に変えてしまいます。
Compose における構成変更とは何ですか?
ヒントがあります。
Francesco Romano
うん
Compose では、構成変更は単なる再コンポーズであり、再コンポーズは常に発生します。
ユーザーがデバイスを開いたなどの理由でウィンドウが広くなると、コンポーザブルは新しいサイズを読み取り、出力がそのサイズに依存するコンポーザブルは再実行されます。
イベントは以上です。
では、最新の Compose ツールとコンポーネントを使用して最新のアダプティブ アプリを作成するとはどういうことかを見ていきましょう。
XML の世界では、「スマートフォン、タブレット、デスクトップのどれで実行しているか」を問い、デバイスのカテゴリに基づいて UI を分岐させていました。
しかし、そうしないのには根本的な理由があります。
まったく同じ実機で実行されている場合でも、アプリケーション コンテキストと UI 機能は実行時に動的に変化する可能性があります。
つまり、UI の決定は、現在のウィンドウ サイズ、ポスチャー、一部のハードウェア特性など、現在アクティブな機能に基づいて行う必要があります。
外部モニターでのデスクトップ ウィンドウという、典型的な例を見てみましょう。
Google Pixel をデスクの外部モニターに接続すると、6 インチの縦向き画面で実行されていたアプリのプロセスが、物理キーボードとマウスが接続された 32 インチのワイドモニターで実行されるようになります。
そのため、アプリが実行されているデバイスの種類を確認しても、実際に表示されている画面や入力モダリティについては何も保証されません。
さらに、Android はマルチウィンドウをサポートしているため、ウィンドウのサイズを変更できます。
Googlebook の画面がスマートフォンより大きくても、アプリは他のアプリとともに幅 400 dp のウィンドウで実行されている可能性があります。
そのため、静的な物理ディスプレイ指標をクエリしても意味がない場合があります。
そのため、Compose ファーストでアプリを設計するには、環境をクエリする方法を理解する必要があります。
まず、UI 機能のコンセプトを形式化することから始めたいと思います。
UI 機能には、ウィンドウの幅と高さ、折りたたみ式デバイスの形状だけでなく、現在アクティブな入出力モダリティも含まれます。
デスクトップ環境では、ユーザーがタッチ スクリーンを常に使用しているわけではないことがわかっています。
キーボードとマウスを使用できる。
自動車システムでは、ロータリーノブや、異なる入力、異なる信号入力方法を備えた車もあります。
また、画面サイズや入力以外にも、ユーザーがディスプレイからどのくらい離れているか(視聴距離など)、ハードウェア カメラやマイクが現在接続されているかどうかなども考慮する必要があります。
以上が UI の機能です。
Francesco Romano
これまでは、すべてのシグナルを取得するには、異なるソースから 5、6 種類の API をつなぎ合わせる必要がありました。
構成オブジェクト、WindowMetricsCalculator、WindowInfoTracker、InputManager、PackageManager が必要で、それぞれを Compose の状態に手動で変換する必要がありました。
楽しかった?
いいえ
この問題を解決するため、androidx.compose.ui で MediaQuery API を導入しました。
mediaQuery { ... }
derivedMediaQuery { ... }これは CSS メディアクエリから着想を得たものです。
メディアという言葉は、音声や動画の再生を意味するものではありません。
これは、UI がレンダリングされる媒体、つまり環境を指します。
MediaQuery を使用すると、Compose ネイティブの単一の宣言型方法で UI 機能をクエリできます。
UiMediaScope で公開されているプロパティを見てみましょう。
sealed interface UiMediaScope {
@get:FrequentlyChangingValue val windowWidth: Dp
@get:FrequentlyChangingValue val windowHeight: Dp
val windowPosture: Posture // Flat, Tabletop, Book
val pointerPrecision: PointerPrecision // Fine, Coarse, Blunt, None
val keyboardKind: KeyboardKind // Physical, Virtual, None
val hasCamera: Boolean
val hasMicrophone: Boolean
val viewingDistance: ViewingDistance // Near, Medium, Far
}windowWidth と windowHeight(Dp 単位)。
windowPosture。これは現時点では折りたたみ式デバイスを対象としており、Flat、Tabletop、Book のいずれかになります。
pointerPrecision は、メインのポインティング デバイスの精度を示します。マウス、タッチペン、トラックパッドの場合は Fine、タッチの場合は Coarse、ジョイスティック、コントローラ、None の場合は Blunt です。
keyboardKind は、physical、virtual、none のいずれかです。
hasCamera と hasMicrophone は、このハードウェアが利用可能かどうかを動的に追跡します。
接続可能な外部カメラについて考えてみましょう。
viewingDistance は、近、中、遠を報告します。
しかし、デバイスがテーブルトップ モードかどうかを検出する実際の例を見た方がわかりやすいと思います。
テーブルトップ モードは、ユーザーがデバイスを半分に折りたたんで、通常は平らな面に置くことができる姿勢です。
また、長時間の動画再生、ビデオ通話、タイムラプス撮影にも最適です。
レシピを見たり、スライドショーを見たりするのにも非常に便利です。
これまでは、MediaQuery の前にテーブルトップの姿勢を検出するには、このコードをすべて記述する必要がありました。
@Composable
fun currentVideoPlayerLayout(): VideoPlayerLayout {
val context = LocalContext.current
val windowLayoutInfo by WindowInfoTracker.getOrCreate(context).windowLayoutInfo(context)
.collectAsStateWithLifecycle(null)
val isTabletopPosture = windowLayoutInfo?.displayFeatures?.any { displayFeature ->
displayFeature is FoldingFeature &&
displayFeature.state == FoldingFeature.State.HALF_OPENED &&
displayFeature.orientation == FoldingFeature.Orientation.HORIZONTAL
} ?: false
return if (isTabletopPosture) {
VideoPlayerLayout.Tabletop
} else {
VideoPlayerLayout.Default
}
}コンテキストを取得し、WindowInfoTracker のインスタンスを取得し、windowLayoutInfo(フロー、ライフサイクル対応の Compose 状態)を収集し、displayFeatures を反復処理して、FoldingFeature、半開き、水平のいずれかであるかどうかを確認する必要がありました。
これは姿勢を検出するためだけのものです。
ボイラープレートが多すぎます。
新しい MediaQuery API を使用すると、これらすべてがわずか数行のコードに縮小されます。
@Composable
fun currentVideoPlayerLayout(): VideoPlayerLayout {
return mediaQuery {
when(windowPosture) {
UiMediaScope.Posture.Tabletop -> VideoPlayerLayout.Tabletop
else -> VideoPlayerLayout.Default
}
}
}mediaQuery 関数は単一のラムダ パラメータを受け取り、そのラムダのレシーバ スコープ内で、先ほど確認した一連のプロパティを公開します。
この例では、when 式で windowPosture をチェックするだけで、適切なコンポーズ可能な関数をすぐに返すことができます。
API の使用方法の例を他に 2 つ紹介します。
@Composable
fun AdaptiveChatControls(
onStartVideoCall: () -> Unit,
onSendMessage: () -> Unit,
) {
// 1. Return a Dp value directly based on pointer precision
val chipPadding = mediaQuery {
if (pointerPrecision == PointerPrecision.Fine) 2.dp else 8.dp
}
// 2. Return a Boolean combining hardware capabilities
val canVideoCall = mediaQuery { hasCamera && hasMicrophone }
val hasPhysicalKeyboard = mediaQuery {
keyboardKind == KeyboardKind.Physical
}
Row(modifier = Modifier.padding(chipPadding)) {
if (canVideoCall) {
IconButton(onClick = onStartVideoCall) {
Icon(Icons.Default.Videocam, contentDescription = "Video call")
}
}
Button(onClick = onSendMessage) {
Text(if (hasPhysicalKeyboard) "Send (Ctrl+Enter)" else "Send")
}
}
}1 つ目は情報密度です。
ユーザーがノートパソコンやデスクトップ モニターでマウスを使用し、ポインタの精度が Fine の場合、UI コンポーネントのパディングを、この例では 8 Dp から 2 Dp に縮小して、より多くのコンテンツを快適に表示できます。ユーザーは指を使用していないため、タップ ターゲットを小さくすることができます。
また、機能駆動型のアクション。
たとえば、チャット入力バーがある場合、hasCamera と hasMicrophone をチェックして、ビデオ通話操作ボタンを動的に表示または非表示にできます。また、キーボードの種類が物理キーボードであるかどうかを組み合わせて、キーボード ショートカットのヒント(Ctrl+Enter キーを押して送信するなど)を表示するかどうかを判断できます。
これらはすべて実行時に取得されます。
また、MediaQuery はテストやプレビューの生成にも適しています。
@Preview(name = "Tabletop + Fine Pointer", widthDp = 840, heightDp = 700)
@Composable
fun TabletopPlayerPreview() {
val simulatedMediaScope = object : UiMediaScope {
override val windowWidth = 840.dp
override val windowHeight = 700.dp
override val windowPosture = UiMediaScope.Posture.Tabletop
override val pointerPrecision = UiMediaScope.PointerPrecision.Fine
override val keyboardKind = UiMediaScope.KeyboardKind.Physical
override val hasCamera = true
override val hasMicrophone = true
override val viewingDistance = UiMediaScope.ViewingDistance.Medium
}
CompositionLocalProvider(LocalUiMediaScope provides simulatedMediaScope) {
VideoPlayerScreen()
}
}つまり、MediaQuery は基本的に、LocalUiMediaScope という CompositionLocal を介してレシーバーを解決します。
そのため、プレビューで CompositionLocalProvider を介して偽の UiMediaScope を提供するだけでよく、Compose UI テストを実行するためだけに折りたたみ式デバイスを起動して 90° にしたり、マウスを接続したりする必要はありません。
Francesco Romano
これで、UI 機能をクエリする方法がわかりました。
では、実際のレイアウトを作成する方法を見てみましょう。
そこで、androidx.compose.foundation.layout の新しいレイアウト プリミティブである Grid をご紹介します。これは現在、安定版になっています。
Grid(
modifier = Modifier.fillMaxSize().padding(16.dp),
config = {
val maxWidthDp = constraints.maxWidth.toDp()
val columnCount = if (maxWidthDp < 600.dp) 2 else 4
repeat(columnCount) {
column(1.fr)
}
row(GridTrackSize.Auto)
row(1.fr)
gap(row = 16.dp, column = 8.dp)
flow = GridFlow.Row
}
) {
// GridScope children...
}Grid を使用すると、利用可能なコンテナサイズに適応する列と行にアイテムを表示して、2 次元レイアウトを実装できます。
config ブロックでできることをいくつか見てみましょう。
column または row を呼び出すことで、新しい要素やトラックを追加できます。また、repeat などの標準の Kotlin ループを使用できます。
トラック間にガターを設定できます。
自動配置の方向は、フロー(GridFlow.Row または GridFlow.Column)で制御できます。
アーキテクチャの重要な詳細を以下に示します。
config ブロックは測定パス中に実行され、グリッド構成スコープは密度を実装して制約を公開します。
つまり、構成内で constraints.maxWidth.toDp() を検査して、余分な再コンポーズ パスなしでトラックを計算できます。
Grid では、子を追加するときに、修飾子なしで子を追加すると、自動的に次のセルに流し込まれます。
Grid(
config = {
repeat(3) { column(1.fr) }
repeat(3) { row(100.dp) }
gap(8.dp)
}
) {
// Spans 2x2 area (rows 1..2, columns 1..2)
HeroCard(modifier = Modifier.gridItem(rows = 1..2, columns = 1..2))
// Overlaps on top of HeroCard in cell (1, 1) aligned to TopStart!
LiveBadge(
modifier = Modifier.gridItem(
row = 1,
column = 1,
alignment = Alignment.TopStart
)
)
// Negative index: placed in last row (-1), spanning all 3 columns
FooterBar(modifier = Modifier.gridItem(row = -1, column = 1, columnSpan = 3))
}明示的な制御が必要な場合は、Modifier.gridItem を使用できます。この場合、1 ベースの座標を使用します。
つまり、行 1、列 1 は左上のセルです。
負のインデックスをサポートしているため、行 -1 は最後の行になります。
rowSpan を使用して、2D 領域全体にアイテムを配置できます。また、重複もあります。
そのため、2 つのアイテムが重複するセルに配置されると、Grid はそれらの境界内でアイテムを重ねて配置し、Z オーダーはコンポジションの順序によって決まります。
そのため、Box と同様に、後のアイテムが他のアイテムの上に描画されます。
これで、Grid の基本がわかりました。
動画プレーヤーの例を見てみましょう。
デバイスが平らな状態か半開きの状態かに応じて、デフォルトのレイアウトとテーブルトップ レイアウトを切り替える必要があります。
Grid が導入される前は、DefaultLayout と TabletopLayout という 2 つの異なるコンポーザブルを作成し、when 式でコードを分岐する必要がありました。
@Composable
fun VideoPlayerLayout(
player: @Composable () -> Unit,
controller: @Composable () -> Unit,
modifier: Modifier = Modifier
) {
when(currentVideoPlayerLayout()) {
VideoPlayerLayout.Default -> DefaultLayout(player, controller, modifier)
VideoPlayerLayout.Tabletop -> TabletopLayout(player, controller, modifier)
}
}「それの何が問題なのか?」と思われるかもしれません。
これらのレイアウトを見ると、コンテンツは同じです。
どちらのディスプレイにも、まったく同じプレーヤーとまったく同じコントローラが表示されます。
2 つのレイアウトの違いは、これら 2 つの要素が画面上でどのように配置されるかだけです。
これまでは、レイアウト配置自体をパラメータとして渡す方法がなかったため、2 つの別々のコンポーザブルを定義する必要がありました。
これで、レイアウト構成をパラメータ化し、1 つのコンポーズ可能な関数内ですべてを処理できるようになりました。
その方法を見ていきましょう。
そこで、Grid API を使用して、名前付きエリアを列挙型として定義できます。
enum class VideoPlayerArea { Player, Controller }
Grid(
config = { ... },
modifier = modifier
) {
Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Player)) {
player()
}
Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Controller)) {
controller()
}
}次に、グリッド内に配置する子コンポーザブル(プレーヤーとコントローラ)を指定し、Modifier.gridItem を呼び出して各子を名前付きエリアに関連付け、areaId を渡します。
ここでは、プレーヤーを VideoPlayerArea.Player という名前の領域に、コントローラを VideoPlayerArea.Controller に配置するよう Grid に指示しています。
ここでは、VideoPlayerGridConfiguration で 2 つの構成ラムダを定義しています。
object VideoPlayerGridConfiguration {
val Default: GridConfigurationScope.() -> Unit = {
row(1f)
column(1f)
area(areaId = VideoPlayerArea.Player, row = 1, column = 1)
area(areaId = VideoPlayerArea.Controller, row = 1, column = 1)
}
val Tabletop: GridConfigurationScope.() -> Unit = {
row(0.5f)
row(0.5f)
column(1f)
area(areaId = VideoPlayerArea.Player, row = 1, column = 1)
area(areaId = VideoPlayerArea.Controller, row = 2, column = 1)
}
}デフォルトの動画プレーヤー レイアウトでは、1 行 1 列のグリッドを構成しますが、セルが 1 つしかなくても、まったく同じセルに 2 つのエリア名を割り当てることができます。
この場合、前述のように、コントローラはプレーヤーの上に重ねて表示されます。
テーブルトップの場合は、2 行 1 列を定義し、プレーヤーを上半分に、コントローラを下半分に配置します。基本的には、このようになります。
これらの構成ラムダを定義したら、グリッドの構成で MediaQuery を直接使用できます。
@Composable
fun VideoPlayerLayout(
player: @Composable () -> Unit,
controller: @Composable () -> Unit,
modifier: Modifier = Modifier
) {
Grid(
config = { mediaQuery {
if (isTabletopPosture) { VideoPlayerGridConfiguration.Tabletop }
else { VideoPlayerGridConfiguration.Default }
}
}
modifier = modifier
) {
Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Player)) {
player()
}
Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Controller)) {
controller()
}
}
}これで、2 つの構成を動的に切り替える UI を備えた、単一の動画プレーヤー レイアウト コンポーザブルを作成できるようになりました。
次に説明するプリミティブは FlexBox です。
@Composable
fun AdaptiveActionHeader(tags: List<String>) {
FlexBox(
config = {
direction( if (isCompact)
FlexDirection.Column else FlexDirection.Row )
wrap(FlexWrap.Wrap)
alignItems(FlexAlignItems.Center)
gap(row = 8.dp, column = 12.dp)
},
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Home",
modifier = Modifier.flex {
basis(FlexBasis.Auto)
grow(1f)
shrink(1f)
}
)
tags.forEach { tag ->
FilterChipItem(
label = tag,
modifier = Modifier.flex { shrink(0f) }
)
}
}
}Grid が 2 次元の行と列のレイアウトを処理するのに対し、FlexBox(こちらも安定版になりました)は、インテリジェントなスペース配分、縮小、複数行の折り返しが必要な 1 次元のレイアウトを処理します。
また、FlowRow と FlowColumn のような 2 つの別々のコンポーザブルの代わりに使用できる、便利でレスポンシブなレイアウトです。これは、子コンポーザブルを再作成せずに、構成ブロック内で方向を動的に変更できる単一のコンテナであるためです。
また、完全な CSS FlexBox アルゴリズムを実装しているため、wrap、justify-content、align-items、gap を使用できます。
各子には、basis、相対的な拡大 / 縮小係数、alignSelf などを指定する Modifier.flex があります。
Francesco Romano
では、画面レベルの定義からアプリレベルのレイアウトにズームアウトして、単一の画面だけでなく、アプリ全体でトップレベルのナビゲーションとマルチペイン画面の配置をどのように構成するかを見ていきましょう。
MediaQuery はウィンドウの正確な幅と高さを Dp で返しますが、アプリレベルのアーキテクチャでは、標準化された、特定の用途に特化したビューポート ブレークポイント(ウィンドウ サイズクラス)が必要です。
基本的には、各バケット内で各画面がレスポンシブになるようにし、バケット間で構造的な変更、更新、違いを増やすことができます。
たとえば、コンパクト画面ではリストのみを表示し、展開ウィンドウではリストと詳細の両方を表示します。
UiMediaScope の拡張プロパティを定義することで、UiMediaScope の上にウィンドウ サイズクラスを直接、非常にクリーンな方法で構築できます。
val UiMediaScope.windowSizeClass: WindowSizeClass
@Composable get() = derivedMediaQuery {
WindowSizeClass.BREAKPOINTS_V2.computeWindowSizeClass(
widthDp = windowWidth.value,
heightDp = windowHeight.value
)
}
@Composable
fun AppRoot() {
val sizeClass = mediaQuery { windowSizeClass }
val isExpandedWidth = sizeClass.isWidthAtLeastBreakpoint(
WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND // 600dp
)
// Drive app-level navigation and scene strategies...
}1 つ注意すべき点は、標準の MediaQuery ではなく derivedMediaQuery を使用していることです。
理由
ウィンドウの幅と高さは頻繁に変化する値であるため。
ユーザーがアプリのサイズを変更しているとき(デスクトップなど)は、これらの値がフレームごとに変化する可能性があります。
また、アプリレベルのスキャフォールドをフレームごとに再コンポーズするのではなく、ウィンドウが実際にブレークポイントをまたいだときにのみ再コンポーズしたい場合もあります。
ウィンドウ サイズクラスを取得したら、スペースが広い場合にアプリをシングルペイン フローからマルチペイン レイアウトに移行するにはどうすればよいでしょうか。
ここで Jetpack Navigation 3 が真価を発揮します。Nav 3 は、Compose の状態とシーンを中心にゼロから設計されているからです。
まず、バックスタックは NavKey のリストにすぎないため、非常に柔軟で、状態として簡単に保存できます。
スタック上の各キーは、コンポーザブルとメタデータを含む NavEntry に解決されます。
また、NavDisplay はエントリの描画方法をハードコードせず、シーン戦略のリストを評価します。
戦略内では、ウィンドウ サイズクラスなどの UI 機能を検査できます。
if (isExpandedWidth) {
/* return a multi-pane scene */
}
else {
/* return a single pane scene */
}バックスタックを検査して、1 つのペインに 1 つの NavEntry をレンダリングするか、複数のアイテムを同時にレンダリングするか、1 つのアイテムを別のアイテムの上にレンダリングするかを決定できます。
非常に柔軟です。
また、ウィンドウ サイズクラスは、マルチペイン戦略だけでなく、人間工学と画面スペースの使用を最大化するために使用するナビゲーション コンポーネントも決定します。
たとえば、コンパクト幅では下部のナビゲーション バー、中程度幅では NavRail、拡大幅ではフル ナビゲーション ドロワーを使用します。
アプリレベルのレイアウト決定に Nav3 を使用し、画面レベルの定義にレスポンシブ コンポーネントを使用すると、アプリはこのような外観になり、ほぼすべてのウィンドウ サイズと環境で優れた表示を実現できます。
Nav 3 の最新情報(ディープリンクのサポートなど)について詳しくは、トーク「Building Type-Safe Deep Links with Jetpack Navigation 3」をご覧ください。
Francesco Romano
今日は、MediaQuery、Grid、FlexBox、Nav3 など、多くの新しい API を紹介しました。
Jetpack Compose を使用してアダプティブ アプリを作成する方法については以上です。
しかし、エージェント開発の時代において、Jetpack Compose を使用することには、もう 1 つ非常に重要な利点があります。それは、はるかに効果的であるということです。
基調講演でもお聞きになったかもしれませんが、Compose に移行したアプリではトークンの使用量が 30% 以上削減されています。Google は、Compose アプリの Agentic Workflows をさらに最適化するよう取り組んでいます。
本日は、Compose Adaptive スキルの主な更新内容について説明します。
android skills list
android skills add adaptive前述のように、レイアウトを Grid、Flexbox、MediaQuery に移行することに加えて、アプリ全体のエンドツーエンドの状態管理を明示的に処理するようになりました。また、ナビゲーション、スクロール位置、アクティブなメディア再生、途中まで入力されたテキスト入力などの保存など、実際の状態損失バグに対してスキルをテストしています。
このスキルには、折りたたみ式デバイスの姿勢の最適化も含まれるようになりました。たとえば、メディア画面やカメラ画面をテーブルトップ モードに自動的にリファクタリングします。ただし、アプリが Googlebook や外部モニターでマウスとキーボードを使用して実行される場合、レイアウトはストーリーの半分にすぎません。
アダプティブ スキルで UI を監査し、キーボード ショートカット、カスタム ポインター カーソル、ホバー状態、ツールチップなどを見つけて実装できるようになりました。
では、実際に見てみましょう。
このデモを録画しました。
アプリができました。
ペットを見ることができます。
メッセージをやり取りできます。
動画で確認してみましょう。
はい、上位のペットが表示されています。
しかし、すでにあまり良くないことがわかります。
では、ドラマと猫が写っている写真を見てみましょう。
わかりました。大きく表示してください。
これはホームに戻るボタン。
わかった。友だちに教えてあげるよ。
「バディ」か。
アプリが良くない。
幅も広すぎます。
幅が広すぎることがわかります。
サイズ変更してみましょう。
何を?
ええ、そうですね。
入力したテキストが失われます。
では、Gemini に相談しましょう。
Adaptive スキルのおかげで、プロンプトは「アプリをアダプティブにする」というシンプルなもので済みます。
ご覧のとおり、Gemini 3.0 Flash を使用して、スキルと組み合わせてこれを実現しています。
スキルが取得され、変更の実行が開始されました。
「MediaQuery は試験運用版です。
本当に使用しますか?」
この調子でがんばりましょう。
動画は早送りしていますが、デモエリアにお越しいただければ、ご自身でスキルを試すことができます。
これは、リリースしたスキルの 1 つにすぎません。
たとえば、優れたタッチペン エクスペリエンスを構築するのに役立つ新しいスキルもあります。
ぜひ、このエリアにお越しください。
これで完了です。
11 個のファイルが変更されました。
では、実際に見てみましょう。
違いますね。
グリッドはすでに機能しています。
ナビゲーションが横に移動しました。
フィルタを試してみましょう。
これで動作すると思います。
はい、そうです。
よし、友だちにメッセージを送って、アプリが使えるようになったことを伝えよう。
スペースを広げて、どのように表示されるか見てみましょう。
はい、リストと詳細のレイアウトに移行しました。
この実行には約 20 分かかり、約 200,000 トークンが使用されました。
違いますね。
Francesco Romano
通常、私はビジネス指標と成功事例から話を始めますが、ADS については、すぐにコードの説明に入りたいと思います。
しかし、エンジニアリングの投資収益率について少しお話ししたいと思います。
現在、Android アプリは 5 億 8,000 万台を超えるアクティブな大画面デバイスで実行できる可能性があります。また、マルチデバイスや折りたたみ式デバイスのユーザーは、Android で最もエンゲージメントの高いセグメントに属しています。
アダプティブ ビルドをリリースするとどうなるかを示す、2 つの具体的なケーススタディを見てみましょう。
1 つ目は、人気のセルフケアおよび日記アプリである Gratitude です。
アダプティブな画面デザインを採用することで、Gratitude はタブレットと折りたたみ式デバイスを、高い成長が見込めるユーザー獲得チャネルとして確立しました。
まず、大画面でのユーザー獲得速度が 2 倍になりました。
タブレットと折りたたみ式デバイスからの 1 日あたりのインストール数は 100% 増加しました。
その結果、ユーザーベース全体に占める大画面ユーザーの割合が、インストール総数の 7% から 10% に増加しました。
2 つ目は、F1 とモータースポーツのコンパニオン アプリである BoxBoxClub です。折りたたみ式デバイスと大画面向けにビルドを更新したところ、テレメトリーで大幅な増加が示されました。全体のアクティブ ユーザーが 7.6% 増加し、エンゲージメント時間が 54% 増加しました。
違いますね。
この講演の最後に、3 つの重要なポイントをお伝えします。
アダプティブなアプリを構築することで、より多くのデバイスとユーザーにアプリを提供できるようになり、拡大するエコシステムにアプリを対応させることができます。
Android 開発は Compose ファーストです。
新しいツール、ライブラリ、スキルはすべて Compose 向けに構築されています。
Compose は、アダプティブ レイアウトとエージェント開発に最適です。
コードベースが小さく、理解しやすいため、エージェントが Compose コードを処理する際に使用するトークンが少なくなります。
ぜひ、新しいツール、スキル、API をすべて試して、楽しんでください。
ありがとうございました
<LinearLayout android:orientation="vertical">
<EditText
android:id="@+id/text_msg"
android:inputType="textCapCharacters"
android:maxLength="12" />
</LinearLayout><ConstraintLayout>
<TextInputEditText
android:id="@+id/text_input"
android:inputType="text" />
</ConstraintLayout>mediaQuery { ... }
derivedMediaQuery { ... }sealed interface UiMediaScope {
@get:FrequentlyChangingValue val windowWidth: Dp
@get:FrequentlyChangingValue val windowHeight: Dp
val windowPosture: Posture // Flat, Tabletop, Book
val pointerPrecision: PointerPrecision // Fine, Coarse, Blunt, None
val keyboardKind: KeyboardKind // Physical, Virtual, None
val hasCamera: Boolean
val hasMicrophone: Boolean
val viewingDistance: ViewingDistance // Near, Medium, Far
}@Composable
fun currentVideoPlayerLayout(): VideoPlayerLayout {
val context = LocalContext.current
val windowLayoutInfo by WindowInfoTracker.getOrCreate(context).windowLayoutInfo(context)
.collectAsStateWithLifecycle(null)
val isTabletopPosture = windowLayoutInfo?.displayFeatures?.any { displayFeature ->
displayFeature is FoldingFeature &&
displayFeature.state == FoldingFeature.State.HALF_OPENED &&
displayFeature.orientation == FoldingFeature.Orientation.HORIZONTAL
} ?: false
return if (isTabletopPosture) {
VideoPlayerLayout.Tabletop
} else {
VideoPlayerLayout.Default
}
}@Composable
fun currentVideoPlayerLayout(): VideoPlayerLayout {
return mediaQuery {
when(windowPosture) {
UiMediaScope.Posture.Tabletop -> VideoPlayerLayout.Tabletop
else -> VideoPlayerLayout.Default
}
}
}@Composable
fun AdaptiveChatControls(
onStartVideoCall: () -> Unit,
onSendMessage: () -> Unit,
) {
// 1. Return a Dp value directly based on pointer precision
val chipPadding = mediaQuery {
if (pointerPrecision == PointerPrecision.Fine) 2.dp else 8.dp
}
// 2. Return a Boolean combining hardware capabilities
val canVideoCall = mediaQuery { hasCamera && hasMicrophone }
val hasPhysicalKeyboard = mediaQuery {
keyboardKind == KeyboardKind.Physical
}
Row(modifier = Modifier.padding(chipPadding)) {
if (canVideoCall) {
IconButton(onClick = onStartVideoCall) {
Icon(Icons.Default.Videocam, contentDescription = "Video call")
}
}
Button(onClick = onSendMessage) {
Text(if (hasPhysicalKeyboard) "Send (Ctrl+Enter)" else "Send")
}
}
}@Preview(name = "Tabletop + Fine Pointer", widthDp = 840, heightDp = 700)
@Composable
fun TabletopPlayerPreview() {
val simulatedMediaScope = object : UiMediaScope {
override val windowWidth = 840.dp
override val windowHeight = 700.dp
override val windowPosture = UiMediaScope.Posture.Tabletop
override val pointerPrecision = UiMediaScope.PointerPrecision.Fine
override val keyboardKind = UiMediaScope.KeyboardKind.Physical
override val hasCamera = true
override val hasMicrophone = true
override val viewingDistance = UiMediaScope.ViewingDistance.Medium
}
CompositionLocalProvider(LocalUiMediaScope provides simulatedMediaScope) {
VideoPlayerScreen()
}
}Grid(
modifier = Modifier.fillMaxSize().padding(16.dp),
config = {
val maxWidthDp = constraints.maxWidth.toDp()
val columnCount = if (maxWidthDp < 600.dp) 2 else 4
repeat(columnCount) {
column(1.fr)
}
row(GridTrackSize.Auto)
row(1.fr)
gap(row = 16.dp, column = 8.dp)
flow = GridFlow.Row
}
) {
// GridScope children...
}Grid(
config = {
repeat(3) { column(1.fr) }
repeat(3) { row(100.dp) }
gap(8.dp)
}
) {
// Spans 2x2 area (rows 1..2, columns 1..2)
HeroCard(modifier = Modifier.gridItem(rows = 1..2, columns = 1..2))
// Overlaps on top of HeroCard in cell (1, 1) aligned to TopStart!
LiveBadge(
modifier = Modifier.gridItem(
row = 1,
column = 1,
alignment = Alignment.TopStart
)
)
// Negative index: placed in last row (-1), spanning all 3 columns
FooterBar(modifier = Modifier.gridItem(row = -1, column = 1, columnSpan = 3))
}@Composable
fun VideoPlayerLayout(
player: @Composable () -> Unit,
controller: @Composable () -> Unit,
modifier: Modifier = Modifier
) {
when(currentVideoPlayerLayout()) {
VideoPlayerLayout.Default -> DefaultLayout(player, controller, modifier)
VideoPlayerLayout.Tabletop -> TabletopLayout(player, controller, modifier)
}
}enum class VideoPlayerArea { Player, Controller }
Grid(
config = { ... },
modifier = modifier
) {
Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Player)) {
player()
}
Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Controller)) {
controller()
}
}object VideoPlayerGridConfiguration {
val Default: GridConfigurationScope.() -> Unit = {
row(1f)
column(1f)
area(areaId = VideoPlayerArea.Player, row = 1, column = 1)
area(areaId = VideoPlayerArea.Controller, row = 1, column = 1)
}
val Tabletop: GridConfigurationScope.() -> Unit = {
row(0.5f)
row(0.5f)
column(1f)
area(areaId = VideoPlayerArea.Player, row = 1, column = 1)
area(areaId = VideoPlayerArea.Controller, row = 2, column = 1)
}
}@Composable
fun VideoPlayerLayout(
player: @Composable () -> Unit,
controller: @Composable () -> Unit,
modifier: Modifier = Modifier
) {
Grid(
config = { mediaQuery {
if (isTabletopPosture) { VideoPlayerGridConfiguration.Tabletop }
else { VideoPlayerGridConfiguration.Default }
}
}
modifier = modifier
) {
Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Player)) {
player()
}
Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Controller)) {
controller()
}
}
}@Composable
fun AdaptiveActionHeader(tags: List<String>) {
FlexBox(
config = {
direction( if (isCompact)
FlexDirection.Column else FlexDirection.Row )
wrap(FlexWrap.Wrap)
alignItems(FlexAlignItems.Center)
gap(row = 8.dp, column = 12.dp)
},
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Home",
modifier = Modifier.flex {
basis(FlexBasis.Auto)
grow(1f)
shrink(1f)
}
)
tags.forEach { tag ->
FilterChipItem(
label = tag,
modifier = Modifier.flex { shrink(0f) }
)
}
}
}val UiMediaScope.windowSizeClass: WindowSizeClass
@Composable get() = derivedMediaQuery {
WindowSizeClass.BREAKPOINTS_V2.computeWindowSizeClass(
widthDp = windowWidth.value,
heightDp = windowHeight.value
)
}
@Composable
fun AppRoot() {
val sizeClass = mediaQuery { windowSizeClass }
val isExpandedWidth = sizeClass.isWidthAtLeastBreakpoint(
WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND // 600dp
)
// Drive app-level navigation and scene strategies...
}if (isExpandedWidth) {
/* return a multi-pane scene */
}
else {
/* return a single pane scene */
}android skills list
android skills add adaptive