mcpField
A
adaptive

adaptive

github.com/android设计与 UI10.0k2.5k 次安装2 天前更新
代码仓库

关于 adaptive

adaptive 是面向使用 Compose 和 Jetpack Navigation 3 的 Android 开发者的技能,帮助应用适配不同设备形态。它指导调整导航、多窗格布局、列表、应用栏和状态保存,并涵盖折叠屏与桌面体验。

主要功能

  1. 01按屏幕尺寸调整导航区域
  2. 02使用 Navigation 3 Scenes 构建多窗格布局
  3. 03根据可用空间调整列表列数
  4. 04配置滚动时的应用栏行为
  5. 05在配置变更后保留 UI 状态
  6. 06处理折叠屏和桌面布局

适用场景

  1. 01将手机导航适配到平板
  2. 02并排显示列表与详情页面
  3. 03为宽屏调整项目网格
  4. 04配置变更后保留输入和滚动状态

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/android/skills --skill adaptive

安装后在 Claude Code 中输入/adaptive确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
custom_cursors.md
foldable-tabletop.md
keyboard_shortcuts.md
tooltips.md
references/android/develop/ui/compose/layouts/adaptive/flexbox/
container-behavior.md
get-started.md
index.md
item-behavior.md
references/android/develop/ui/compose/layouts/adaptive/grid/
container-properties.md
get-started.md
index.md
item-properties.md
references/android/develop/ui/compose/layouts/adaptive/mediaquery/
index.md
references/android/guide/navigation/navigation-3/
save-state.md
references/android/guide/navigation/navigation-3/recipes/
material-listdetail.md
references/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):

  1. 使用 rememberSaveable(videoResId) 存储 savedPositionMs(mutableLongStateOf(0L))和 savedPlayWhenReady(mutableStateOf(true))。
  2. 持续更新已保存的状态——不要只在 onDispose 中保存:在 Player.Listener(onPlayWhenReadyChanged、onPositionDiscontinuity)中更新 savedPlayWhenReady 和 savedPositionMs,并且在播放期间通过 LaunchedEffect(player) 循环定期更新。
  3. 使用恢复的状态初始化播放器:设置 playWhenReady = savedPlayWhenReady,并在调用 prepare() 之前调用 seekTo(savedPositionMs)。
  4. 谨慎使用 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:运行针对特定功能的处理

根据确定的功能,必须阅读相应的参考文件 并遵循其中的说明:

每份参考文件都会先搜索整个应用,查找该功能的目标位置。先记下 该列表,将所有位置都实现完毕(而不是只处理打开的第一个屏幕),并在结束前重新检查列表。

第 8 步:支持折叠设备

桌面模式允许用户将折叠设备放在平面上时查看并与内容交互。上方屏幕倾斜至舒适的观看角度,用于显示视觉内容。下方屏幕平放在表面上,用于提供内容控制项。

要支持折叠设备,请找出提供以下功能的屏幕:

  • 媒体和音频播放或流式传输:视频或音频内容显示在竖起的上方屏幕上。评论、详情和播放进度条控制项显示在平放的下方表面上。
  • 视频通话:远程参与者显示在竖起的屏幕上。麦克风和摄像头开关、参与者列表及聊天控制项位于平放的下方表面上。
  • 相机拍摄:相机取景器显示在竖起的屏幕上。快门按钮、缩放控制项和曝光滑块位于平放的下方表面上,便于免手持拍摄。
  • 照片编辑:图像画布在竖起的屏幕上无遮挡地显示。滑块、预设调色板和曲线控制项在平放的下方表面上保持可用。
  • 音频混音:波形和音轨控制项显示在竖起的屏幕上。转盘和打击垫位于平放的下方表面上。
  • 烹饪:在竖起的屏幕上免手持查看食谱说明,同时在下方屏幕上显示计时器或步骤导航。
  • 幻灯片:在上方屏幕上查看演示文稿幻灯片,同时在下方屏幕上显示演讲者备注或导航。

要为这些屏幕创建折叠设备布局,必须阅读桌面姿态。

最后一步:构建和测试

构建应用并运行本地测试。如果项目有截图测试,请运行测试,但不要更新参考图像。提示用户在查看截图差异后执行此操作。

实验性自适应 API 的其他文档

以下 API 从 Compose 1.11.0 开始提供。

FlexBox

查看 FlexBox 文档:

MediaQuery

需要查询设备屏幕尺寸、指针精度、键盘类型、是否配备摄像头或麦克风以及其他设备能力时,请查看 MediaQuery 文档。

Grid

需要在网格布局中显示固定数量的项目时,请查看 Grid 文档:

评论

支持 Markdown