adaptive について
adaptive は、Compose と Jetpack Navigation 3 を使う Android 開発者向けのスキルで、アプリをさまざまなフォームファクターに適応させます。ナビゲーション、ペインレイアウト、リスト、アプリバー、状態保持の変更を案内し、折りたたみ式端末やデスクトップにも対応します。
主な機能
- 01画面サイズに応じたナビゲーション領域の調整
- 02Navigation 3 Scenes によるマルチペインレイアウト
- 03利用可能な幅に応じたリスト列数の調整
- 04スクロール時のアプリバー動作の設定
- 05構成変更後の UI 状態の保持
- 06折りたたみ式端末とデスクトップのレイアウト対応
活用シーン
- 01スマートフォン向けナビゲーションのタブレット対応
- 02リストと詳細画面の並列表示
- 03幅の広い画面に合わせたグリッド調整
- 04構成変更後の入力内容とスクロール位置の保持
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ android/ skills --skill adaptiveインストール後、Claude Code で/adaptiveを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
references/custom_cursors.mdfoldable-tabletop.mdkeyboard_shortcuts.mdtooltips.mdreferences/android/develop/ui/compose/layouts/adaptive/flexbox/container-behavior.mdget-started.mdindex.mditem-behavior.mdreferences/android/develop/ui/compose/layouts/adaptive/grid/container-properties.mdget-started.mdindex.mditem-properties.mdreferences/android/develop/ui/compose/layouts/adaptive/mediaquery/index.mdreferences/android/guide/navigation/navigation-3/save-state.mdreferences/android/guide/navigation/navigation-3/recipes/material-listdetail.mdreferences/android/studio/preview/compose-screenshot-testing-with-testsuites.md詳細
前提条件
アプリは次の条件を満たす必要があります。
- すべての画面で Compose を使用します。まだ Fragments や Views を使用している場合は、それらの画面を移行するために XML to Compose スキルを提案してください。
- Jetpack Navigation 3 を使用します。使用していない場合は、アプリを移行するために Navigation 3 スキルを提案してください。
アプリをアダプティブにするワークフロー
アプリをアダプティブにするには、タスクに合わせて以下の手順のすべて、または一部を実施します。
- ステップ 1:現在の UI を確認する
- ステップ 2:ナビゲーションバーをアダプティブにする
- ステップ 3:複数ペインレイアウトを追加する
- ステップ 4:列数を変更して、垂直リストをアダプティブにする
- ステップ 5:スクロール時にアプリバーを非表示にする
- ステップ 6:構成変更後も UI の状態を保持する
- ステップ 7:デスクトップ向けの操作環境を追加する
- ステップ 8:折りたたみ式デバイスに対応する
ステップ 1:現在の UI を確認する
さまざまなフォームファクターで現在の UI を検証するスクリーンショットテストがあることを確認します。ない場合は、Compose Preview Screenshot Testing ツールを追加します。以下のアノテーションを使用して、主要なフォームファクターすべてのプレビューを作成します。例:
@Preview(name = "Phone", device = Devices.PHONE, showBackground = true)
@Preview(name = "Foldable", device = Devices.FOLDABLE, showBackground = true)
@Preview(name = "Tablet", device = Devices.TABLET, showBackground = true)
@Preview(name = "Desktop", device = Devices.DESKTOP, showBackground = true)
annotation class FormFactorPreviews
@PreviewTest
@FormFactorPreviews
@Composable
fun FeedScreenPreview() {
SnippetsTheme {
Box {
Text("My Screen")
}
}
}
ステップ 2:ナビゲーションバーをアダプティブにする
下部ナビゲーションバーは、ユーザーがスマートフォンを縦向きに持ってタッチ操作する場合に最適化されています。タブレットや展開した折りたたみ式デバイスなど、画面の大きな携帯型デバイスでは、ナビゲーション領域に画面の端からアクセスできるようにする必要があります(ナビゲーションレール)。
コンテンツに使える画面領域を広げる必要がある場合は、ナビゲーション領域を非表示にします。次のような例があります。
- ユーザーが下にスクロールしたときにナビゲーションバーを非表示にし、上にスクロールしたときに再表示します。ユーザーは下にスクロールするときにコンテンツを読んでおり、上にスクロールするときはコンテンツから移動しようとしていると想定します。
- ナビゲーション領域のコンテンツが邪魔になる場合は、ナビゲーション領域を非表示にします。たとえば、カメラのプレビューや写真を全画面表示する場合です。
モバイルで詳細画面を全画面表示する場合、大画面では全画面モードを解除する必要があります。
移行手順:
- 既存のナビゲーションバーを探します。
- 各項目を
NavigationSuiteItemに変換します。 - ナビゲーションバーの表示状態が変化するかどうかを確認します。たとえば、
AnimatedContentまたはAnimatedVisibilityコンポーザブルで囲まれている場合です。その場合は、「ナビゲーション領域の表示状態を制御する」のガイダンスに従います。 - ナビゲーションバーを含むコンテナー(多くの場合は
Scaffold)を、Material 3 adaptive layouts ライブラリのNavigationSuiteScaffoldに置き換えます。 NavigationSuiteScaffoldのnavigationItemsパラメーターを使ってナビゲーション項目を指定します。
ステップ 2.1:ナビゲーション領域の表示状態を制御する
ナビゲーションバーの表示状態が変化する場合、つまり特定の状況や画面で非表示になる場合は、アダプティブなナビゲーション領域でもこの動作を維持する必要があります。これは NavigationSuiteScaffold の state パラメーターを使って行います。
移行手順:
- ナビゲーションバーが非表示になる状況を特定します。通常は、表示状態を表すブール値で管理します。変数名には
isNavBarVisibleまたはshouldShowNavBarを使用します。 rememberNavigationSuiteScaffoldState()を使ってNavigationSuiteScaffoldStateのインスタンスを作成し、NavigationSuiteScaffoldに渡します。- ナビゲーション領域の表示状態が変わったときに、
LaunchedEffectを使ってNavigationSuiteScaffoldStateのshowまたはhideを呼び出します。
例:
// Pass this variable to any composable that needs to control the navigation area visibility
var isNavBarVisible by remember { mutableStateOf(true) }
val scaffoldVisibilityState = rememberNavigationSuiteScaffoldState()
NavigationSuiteScaffold(
navigationSuiteItems = navItems,
state = scaffoldVisibilityState
) {
// Main content
}
LaunchedEffect(isNavBarVisible){
if (isNavBarVisible) {
scaffoldVisibilityState.show()
} else {
scaffoldVisibilityState.hide()
}
}
ステップ 3:Navigation 3 Scenes を使って複数ペインレイアウトを追加する
コードベースを調べて、関連する画面を探します。ある画面で何かをタップすると、最初の画面に関連する情報を表示する別の画面が開く、といった画面の関係です。代表的な画面の関係には、リストと詳細、および補助ペインの 2 つがあります。
重要:複数ペインレイアウトを実装するには、Navigation 3 の SceneStrategy アプローチを使用する必要があります。ListDetailPaneScaffold や SupportingPaneScaffold は使用しないでください。
ステップ 3.1:リストと詳細
リスト画面と詳細画面を特定する
リスト・詳細レイアウトでは、項目のリスト(リスト画面)が表示され、 項目をクリックすると、その項目の詳細を表示する新しい画面(詳細画面)が 開きます。
メール、メモ、メッセージなどの生産性向上アプリでよく使われます。
明示的に求められていない限り、詳細コンテンツにかなりの画面スペースが 必要な場合(例:全画面表示が適した画像やメディア)は、このパターンを 避けてください。
Material のリスト・詳細 SceneStrategy を追加する
androidx.compose.material3.adaptive:adaptive-navigation3ライブラリを追加しますrememberListDetailSceneStrategyを使って、androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategyを作成しますsceneStrategiesパラメーターを使って、ListDetailSceneStrategyをNavDisplayに渡します
メタデータを使ってリスト画面と詳細画面を特定する
ListDetailSceneStrategy.listPane(detailPlaceholder = { <placeholder composable> })を使って、entry(metadata = ...)またはNavEntry(metadata = ...)でリストのエントリーにメタデータを追加します。- リスト項目が選択されていない場合に詳細画面にプレースホルダーを追加するには、
detailPlaceholderパラメーターを使います。 ListDetailSceneStrategy.detailPane()を使って、詳細エントリーにメタデータを追加します。
重要な考慮事項
- モバイルで詳細画面のコンテンツを全画面表示する場合(コンテンツが画面全体を占め、バーやレールが非表示になる場合)、リスト・詳細レイアウトの一部として使用するなら、全画面モードを無効にする必要があります。
- リスト・詳細レイアウトでは、詳細画面に戻る矢印を表示してはいけません。
実装例については、Nav3 Material のリスト・詳細レシピ をご覧ください。
ステップ 3.2. 補助ペイン
メイン画面に単一の項目が表示され、その項目を選択すると詳細を示す 「補助画面」が開く画面を特定します。補助画面はメイン画面を補完し、 補助ペインに表示されます。
Material の補助ペイン SceneStrategy を追加する
- まだ追加していない場合は、
androidx.compose.material3.adaptive:adaptive-navigation3ライブラリを追加します rememberSupportingPaneSceneStrategyを使って、androidx.compose.material3.adaptive.navigation3.SupportingPaneSceneStrategyを作成しますsceneStrategiesパラメーターを使って、SupportingPaneSceneStrategyをNavDisplayに渡します
メタデータを使ってメイン画面と補助画面を特定する
SupportingPaneSceneStrategy.mainPane()を使って、entry(metadata = ...)またはNavEntry(metadata = ...)でメインエントリーにメタデータを追加しますSupportingPaneSceneStrategy.supportingPane()を使って、補助エントリーにメタデータを追加します
ステップ 3.3. スクリーンショットテストを実行する
変更を加えた場合は、新しい参照ファイルを記録します。新しいレイアウトが 正しいことをユーザーに目視で確認してもらいます。
ステップ 4. 列数を変更して縦方向のリストをアダプティブにする
ステップ 4.1. 遅延リストをアダプティブにする
次の縦方向のリストコンポーザブルを探します:LazyColumn、
LazyVerticalGrid、LazyVerticalStaggeredGrid。
移行手順:
- 列に適した最小幅を dp で選びます。この幅で項目がユーザーにはっきり見える必要があります。
LazyColumnの場合:LazyVerticalGridに変更し、後述の手順に従いますLazyVerticalGridの場合:columnsパラメーターをGridCells.Adaptive(<width>.dp)に変更しますLazyVerticalStaggeredGridの場合:columnsパラメーターをStaggeredGridCells.Adaptive(<width>.dp)に変更します
Step 4.2. 非遅延リストを Grid に移行する
警告:Grid は Compose 1.11.0-beta01 から利用できる実験的な API です。 コードベースで実験的な API を使用しても問題ないか、ユーザーに確認してください。
同じ型のアイテムが複数含まれている Column を探し、Grid に置き換えてください。
LazyVerticalGrid や、ほかの遅延レイアウトには置き換えないでください。既存の Column の中に Grid を配置しないでください。Column 全体を置き換えてください。
Grid は、config パラメーターにラムダ(GridConfigurationScope の拡張関数)を渡して設定します。ラムダ内の constraints には、グリッドコンテナの最小寸法と最大寸法が含まれており、利用可能なサイズに応じて行数と列数を変更するために使用できます。たとえば、次のコードでは、利用可能な幅に応じて Grid を次のように設定します。
- 800dp 未満の場合は 2x4 のグリッドを使用します
- 800dp 以上の場合は 4x2 のグリッドを使用します
Grid(
config = {
val maxWidthDp = constraints.maxWidth.toDp()
val (cols, rows) = if (maxWidthDp < 800.dp){
2 to 4
} else{
4 to 2
}
val gapSizeDp = 8.dp
val cellSize = ((maxWidthDp - (gapSizeDp * (cols - 1))) / cols).coerceAtLeast(0.dp)
repeat(cols) { column(cellSize) }
repeat(rows) { row(cellSize) }
gap(gapSizeDp)
}
) { /** items **/ }
Grid は実験的な API であるため、使用する関数に @OptIn(ExperimentalGridApi::class) アノテーションを追加してください。
Step 5: スクロール時にアプリバーを非表示にする
複数のトップレベルの遷移先があるアプリでは、各画面がそれぞれ独立してアプリバーの状態を管理する必要があります。主なスクロール動作は次の 2 つです。
exitUntilCollapsedScrollBehavior:下にスクロールすると非表示になり、一番上(オフセット 0)に達するまで上にスクロールしても非表示のままです。enterAlwaysScrollBehavior:下にスクロールすると非表示になり、上にスクロールするとすぐに表示されます。
Step 6: 構成変更後も UI の状態を保持する
重要:Activity の再生成を抑制したり、画面の向きを固定したりするために、AndroidManifest.xml に android:configChanges または android:screenOrientation を絶対に追加しないでください。再生成を無効にすると、状態消失のバグが修正されずに隠され、アダプティブなリソースの再読み込みが妨げられ、アプリのプロセスの再起動もできなくなります。
構成変更後の状態消失を修正する際は、すべての画面、コンポーネント、状態ホルダーで remember の使用箇所をすべて体系的に確認してください(つまり、remember { mutableStateOf(...) } だけを検索しないでください)。
Step 6.1. Composable 内のプリミティブな UI 状態とカスタム UI 状態を保持する
- ユーザー入力や、
TextFieldの入力中テキスト、検索クエリ、スクロール位置、トグル状態などのインタラクティブな UI 状態には、remember { mutableStateOf(...) }をrememberSaveable { mutableStateOf(...) }に置き換えてください。 - 適切な場合は、関連する識別子を状態のキーとして指定してください(例:
rememberSaveable(conversationId) { mutableStateOf("") })。 - ネイティブに
Bundleへ保存できないカスタムデータクラスやコレクション(たとえばSet)の場合:var state by rememberSaveable(...) { mutableStateOf(...) }でMutableState<T>を委譲する場合は、カスタムのSaverをstateSaverで渡してください(ブロックがMutableState<T>を返す場合、saver = ...は渡さないでください)。
// this Saver implementation converts CustomState object which we don't know how to save
// to List<String> which we can save
private val CustomStateSaver = Saver<CustomState, List<String>>(
save = { customState -> customState.values.map { it.value } },
restore = { values -> CustomState(values.map { CustomStateValue.valueOf(it) }.toSet()) }
)
@Composable
fun MyComposable() {
var customState by rememberSaveable(stateSaver = CustomStateSaver) {
mutableStateOf(CustomState())
}
}
- または、クラスに
@Serializableアノテーションを付けてrememberSerializableを使用してください。
Step 6.2. 遅延レイアウトとスクロール可能なレイアウトのスクロール状態を保持する
通常の remember { LazyListState() }、remember { LazyGridState() }、および
remember { ScrollState(...) } は、状態クラスの Saver を迂回するため、
構成変更後のスクロール位置を保存しません。
すべての画面と再利用可能なコンポーネントで、スクロール可能なコンテナ(LazyColumn、LazyRow、LazyVerticalGrid、
LazyHorizontalGrid、LazyVerticalStaggeredGrid、Modifier.verticalScroll、
Modifier.horizontalScroll)を調べ、通常の remember によるインスタンス化を、組み込みの保存可能なファクトリー関数に置き換えてください。
remember { LazyListState() }->rememberLazyListState()remember { LazyGridState() }->rememberLazyGridState()remember { LazyStaggeredGridState() }->rememberLazyStaggeredGridState()remember { ScrollState(initial = 0) }->rememberScrollState()
ステップ 6.3. Navigation 3 の状態と複数のバックスタックを保持する
詳細については、Navigation 3 ガイドのナビゲーション状態の保存と管理を確認してください。
ステップ 6.4. メディア再生状態(ExoPlayer / Media3)を保持する
remember を使ってコンポーザブル内で MediaPlayer(ExoPlayer など)を作成すると、破棄時に解放され、構成変更時に最初から作り直されます。その結果、再生位置が 0:00 にリセットされ、ユーザーが動画を一時停止していた場合でも自動再生されます。
ExoPlayer の再生状態(currentPosition と playWhenReady)を保持するには、次の手順に従います。
rememberSaveable(videoResId)を使って、savedPositionMs(mutableLongStateOf(0L))とsavedPlayWhenReady(mutableStateOf(true))を保存します。- 再生中も保存状態を更新します。
onDisposeでのみ保存しないでください:Player.Listener(onPlayWhenReadyChanged、onPositionDiscontinuity)でsavedPlayWhenReadyとsavedPositionMsを更新し、さらにアクティブな間はLaunchedEffect(player)内のループで定期的に更新します。 - 復元した状態でプレーヤーを初期化します:
playWhenReady = savedPlayWhenReadyを設定し、prepare()の前にseekTo(savedPositionMs)を呼び出します。 LifecycleStartEffectが一時停止中の動画を自動再生したり、savedPlayWhenReadyを上書きしたりしないようにします:LifecycleStartEffect(player)では、savedPlayWhenReadyがtrueの場合にのみplayer.play()を呼び出します(そうしないと、復元後に一時停止中の動画が再び再生されます)。onStopOrDisposeでは、player.pause()を呼び出す前にval wasPlaying = player.playWhenReadyとsavedPositionMs = player.currentPositionを取得し、その後savedPlayWhenReady = wasPlayingに戻します。これにより、停止/破棄時のplayer.pause()がsavedPlayWhenReadyをfalseに上書きするのを防ぎます。
@UnstableApi
@Composable
private fun rememberExoPlayer(@RawRes videoResId: Int, fromUri: Unit.(Uri) -> MediaItem): ExoPlayer {
val context = LocalContext.current
var savedPositionMs by rememberSaveable(videoResId) { mutableLongStateOf(0L) }
var savedPlayWhenReady by rememberSaveable(videoResId) { mutableStateOf(true) }
val player = remember(videoResId) {
ExoPlayer.Builder(context.applicationContext).build().apply {
setMediaItem(MediaItem.fromUri(RawResourceDataSource.buildRawResourceUri(videoResId)))
repeatMode = Player.REPEAT_MODE_ONE
playWhenReady = savedPlayWhenReady
seekTo(savedPositionMs)
prepare()
}
}
DisposableEffect(player) {
val listener = object : Player.Listener {
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
savedPlayWhenReady = playWhenReady
savedPositionMs = player.currentPosition
}
override fun onPositionDiscontinuity(
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
reason: Int
) {
savedPositionMs = newPosition.positionMs
}
}
player.addListener(listener)
onDispose {
player.removeListener(listener)
player.release()
}
}
LaunchedEffect(player) {
while (isActive) {
savedPositionMs = player.currentPosition
delay(200)
}
}
LifecycleStartEffect(player) {
if (savedPlayWhenReady) {
player.play()
}
onStopOrDispose {
val wasPlaying = player.playWhenReady
savedPositionMs = player.currentPosition
player.pause()
savedPlayWhenReady = wasPlaying
}
}
return player
}
ステップ 7:デスクトップエクスペリエンスを追加する
ステップ 7.1:必要なデスクトップ機能を特定する
ユーザーのプロンプト、要件、またはアップロードされたファイルを確認し、実装が必要なデスクトップ機能を特定します。
ステップ 7.2:機能に応じた処理を実行する
特定した機能に応じて、対応するリファレンスファイルを読み、その指示に従ってください。
- キーボードショートカット ->
references/keyboard_shortcuts.mdを読む - ツールチップ ->
references/tooltips.mdを読む - カスタムカーソル ->
references/custom_cursors.mdを読む
各リファレンスでは、まずアプリ全体を検索して、その機能の対象を見つけます。最初に対象の一覧を書き出し、すべてに実装し(最初に開いた画面だけではなく)、作業を終える前に一覧を再確認してください。
ステップ 8. 折りたたみ式デバイスをサポートする
テーブルトップモードでは、折りたたみ式デバイスを平らな面に置いたまま、コンテンツを表示して操作できます。上側の画面は見やすい角度に傾き、視覚コンテンツを表示します。下側の画面は平らな面に置かれ、そのコンテンツを操作するためのコントロールを表示します。
折りたたみ式デバイスをサポートするには、以下を提供する画面を特定します。
- メディアや音声の再生またはストリーミング:動画や音声のコンテンツは、直立した上側のディスプレイに表示します。コメント、詳細、再生位置を調整するコントロールは、平らな下側の画面に表示します。
- ビデオ通話:リモート参加者は直立した画面に表示します。マイクとカメラの切り替え、参加者リスト、チャットのコントロールは、平らな下側の画面に配置します。
- カメラ撮影:カメラのビューファインダーは直立した画面に表示します。シャッターボタン、ズームコントロール、露出スライダーは、ハンズフリーで撮影できるよう平らな下側の画面に配置します。
- 写真編集:画像キャンバスは直立した画面に遮るものなく表示します。スライダー、プリセットパレット、カーブは平らな下側の画面で操作できるようにします。
- オーディオミキシング:波形とステムのコントロールは直立した画面に表示します。ターンテーブルのデッキとパッドは平らな下側の画面に配置します。
- 料理:レシピの手順を直立した画面でハンズフリーで確認しながら、タイマーや手順の移動操作は下側の画面に表示します。
- スライドショー:プレゼンテーションのスライドを上側の画面に表示し、発表者ノートや移動操作を下側の画面に表示します。
これらの画面向けに折りたたみ式デバイスのレイアウトを作成するには、 テーブルトップ姿勢を確認する必要があります。
最終ステップ:ビルドとテスト
アプリをビルドし、ローカルテストを実行します。プロジェクトにスクリーンショットテストがある場合は、実行しますが、参照画像は更新しないでください。スクリーンショットの差分を確認した後、ユーザーに更新するよう促してください。
実験的なアダプティブ API の追加ドキュメント
以下の API は Compose 1.11.0 から利用できます。
FlexBox
FlexBox のドキュメントを確認してください。
MediaQuery
デバイスの画面サイズ、ポインターの精度、キーボードの種類、カメラやマイクの有無、その他のデバイス機能を取得する必要がある場合は、 MediaQuery のドキュメントを確認してください。
Grid
固定数のアイテムをグリッドレイアウトで表示する必要がある場合は、Grid のドキュメントを確認してください。