关于 adaptive
adaptive 是面向使用 Compose 和 Jetpack Navigation 3 的 Android 开发者的技能,帮助应用适配不同设备形态。它指导调整导航、多窗格布局、列表、应用栏和状态保存,并涵盖折叠屏与桌面体验。
主要功能
- 01按屏幕尺寸调整导航区域
- 02使用 Navigation 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 转 Compose 技能迁移这些屏幕。
- 使用 Jetpack Navigation 3。如果没有使用,建议使用 Navigation 3 技能迁移应用。
使应用适配不同屏幕的工作流
要使应用适配不同屏幕,请按以下步骤操作,也可以根据任务需要选择其中一部分。
- 步骤 1:检查当前 UI
- 步骤 2:使导航栏具备自适应能力
- 步骤 3:添加多窗格布局
- 步骤 4:通过调整列数,使垂直列表具备自适应能力
- 步骤 5:滚动时隐藏应用栏
- 步骤 6:在配置更改后保留 UI 状态
- 步骤 7:添加桌面体验
- 步骤 8:支持可折叠设备
步骤 1. 检查当前 UI
确保存在截图测试,以验证当前 UI 在不同外形尺寸设备上的表现。如果没有,请添加 Compose Preview 截图测试工具 。使用以下注解,为所有主要外形尺寸创建设计预览。例如:
@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 自适应布局库中的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 添加多窗格布局
分析代码库,查找彼此相关的屏幕——在一个屏幕上点击某项内容后,会打开另一个显示相关信息的屏幕。两种典型的屏幕关系是列表-详情和辅助窗格。
重要:必须使用 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
使用元数据识别列表和详情界面
- 使用
entry(metadata = ...)或NavEntry(metadata = ...)为列表条目添加元数据,并使用ListDetailSceneStrategy.listPane(detailPlaceholder = { <placeholder composable> })。 - 使用
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
使用元数据识别主界面和辅助界面
- 使用
entry(metadata = ...)或NavEntry(metadata = ...)为主条目添加元数据,并使用SupportingPaneSceneStrategy.mainPane() - 使用
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)
步骤 4.2. 将非延迟列表迁移到 Grid
警告:Grid 是 Compose 1.11.0-beta01 提供的实验性 API。 请确认用户愿意在其代码库中使用实验性 API。
查找包含多个相同类型项的任何 Column,并将其替换为
Grid。不要将其替换为 LazyVerticalGrid 或任何其他延迟布局。不要在现有的
Column 内放置 Grid,而是将其完全替换。
Grid 通过向其 config 参数传入一个 lambda(GridConfigurationScope 的扩展函数)
进行配置。在 lambda 中,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)
注解。
步骤 5:滚动时隐藏应用栏
在包含多个顶层目的地的应用中,每个屏幕都必须独立管理自己的 应用栏状态。主要有两种滚动行为:
exitUntilCollapsedScrollBehavior:向下滚动时隐藏;向上滚动时保持隐藏,直到到达最顶部(偏移量为 0)。enterAlwaysScrollBehavior:向下滚动时隐藏,向上滚动时立即显示。
步骤 6:在配置更改后保留 UI 状态
重要:绝不要在 AndroidManifest.xml 中添加 android:configChanges 或 android:screenOrientation
来阻止 Activity 重新创建或锁定屏幕方向。选择退出重新创建只会掩盖状态丢失问题,而非修复
问题,还会破坏自适应资源的重新加载,并阻止应用进程重新启动。
修复配置更改导致的状态丢失时,请系统地审查所有屏幕、组件和状态持有者中
每一处 remember 的用法(换言之,不要只搜索 remember { mutableStateOf(...) }):
步骤 6.1:在 Composable 中保留基本类型和自定义 UI 状态
- 对用户输入和交互式 UI 状态(例如
TextField草稿文本、搜索查询、滚动位置和切换状态),将remember { mutableStateOf(...) }替换为rememberSaveable { mutableStateOf(...) }。 - 适当时,根据相关标识符为状态设置键(例如
rememberSaveable(conversationId) { mutableStateOf("") })。 - 对于不能原生保存到
Bundle的自定义数据类或集合(例如Set):- 使用
var state by rememberSaveable(...) { mutableStateOf(...) }委托MutableState<T>时,请使用stateSaver传入自定义Saver(如果代码块返回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。
步骤 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 文档: