1. 简介
在此 Codelab 中,您将学习如何借助 Jetpack Compose 构建适用于手机、平板电脑和可折叠设备的自适应应用,以及如何改进这些应用的可单手操作性。您还将学习关于使用 Material 3 组件和主题的最佳实践。
在深入了解这方面内容之前,我们有必要先了解一下自适应的含义。
适应性工具
应用的界面应能适应不同的窗口大小、屏幕方向和外形规格。自适应布局会根据可用的屏幕空间自动调整。这些调整包括简单的布局调整(以填满空间)、选择相应导航栏样式,以及完全更改布局(以利用额外的空间)。
如需了解详情,请参阅自适应设计。
在此 Codelab 中,您将探索在使用 Jetpack Compose 时如何使用和看待自适应。您将构建一款名为 Reply 的应用,它将向您展示如何针对各种屏幕实现自适应,以及可自适应性和可单手操作性如何协同工作,以便为用户提供最佳体验。
学习内容
- 如何使用 Jetpack Compose 设计适用于所有窗口尺寸的应用。
- 如何使应用适用于不同的可折叠设备。
- 如何使用不同类型的导航栏来实现更好的单手操作和无障碍功能。
- 如何使用 Material 3 组件针对各种窗口尺寸提供最佳体验。
所需条件
- 最新的稳定版 Android Studio。
- Android 13 可调整大小的虚拟设备。
- 了解 Kotlin。
- 对 Compose(如
@Composable注解)有基本的了解。 - 基本熟悉 Compose 布局(例如
Row和Column)。 - 基本熟悉修饰符(例如
Modifier.padding())。
在此 Codelab 中,您将使用可调整大小的模拟器,该模拟器可让您在不同的设备类型和窗口大小之间进行切换。

如果您不熟悉 Compose,建议您在学习此 Codelab 之前先完成 Jetpack Compose 基础知识 Codelab。
构建内容
- 一款名为 Reply 的互动式电子邮件客户端应用,采用关于自适应设计、不同 Material 导航栏和最佳屏幕空间使用方式的最佳实践。

2. 进行设置
如需获取此 Codelab 的代码,请从命令行克隆 GitHub 代码库:
git clone https://github.com/android/codelab-android-compose.git cd codelab-android-compose/AdaptiveUiCodelab
或者,您也可以下载代码库 Zip 文件。
我们建议您从 main 分支中的代码着手,按照自己的节奏逐步完成此 Codelab。
在 Android Studio 中打开项目
- 在 Welcome to Android Studio 窗口中,选择
Open an Existing Project。 - 选择文件夹
<Download Location>/AdaptiveUiCodelab(提示:务必选择包含build.gradle的AdaptiveUiCodelab目录)。 - Android Studio 导入项目后,请测试您能否运行
main分支。
探索起始代码
main 分支代码包含 ui 软件包。您将使用该软件包中的以下文件:
MainActivity.kt- 用于启动应用的入口点 activity。ReplyApp.kt- 包含主屏幕界面可组合项。ReplyHomeViewModel.kt- 为应用内容提供数据和界面状态。ReplyListContent.kt- 包含用于提供列表和详细信息屏幕的可组合项。
如果您在可调整大小的模拟器上运行该应用,并尝试采用不同的设备类型(例如手机或平板电脑),界面只会展开至给定空间,而不会充分利用屏幕空间或提供符合可单手操作性要求的人体工学体验。


您将更新该应用,以便充分利用屏幕空间、提高易用性并改善整体用户体验。
3. 使应用具有自适应能力
本部分将介绍使应用具有自适应能力意味着什么,以及 Material 3 提供了哪些组件来简化相关工作。此外,它还介绍了您要定位的屏幕和状态类型,包括手机、平板电脑、大型平板电脑和可折叠设备。
首先,您将了解有关窗口大小、折叠状态和不同类型的导航栏选项的基础知识。然后,您可以在应用中使用这些 API,以便提高应用的自适应性。
窗口大小
Android 设备形状各异且尺寸不一,从手机、可折叠设备到平板电脑和 ChromeOS 设备,不一而足。为了尽量支持更多窗口尺寸,您的界面需要具备自适应能力。为帮助您找到用于更改应用界面的正确阈值,我们定义了断点值,以帮助您将设备分类为预定义的大小类别(紧凑、中等和展开)。这些断点值称为窗口大小类别。窗口大小类别是一组主观的视口断点,有助于您设计、开发并测试响应式和自适应布局。
这些类别是我们专门选择的,目的是平衡布局简单性,以便针对独特情形灵活地优化您的应用。窗口大小类别始终由应用的可用屏幕空间决定,而出于多任务处理或其他分屏目的,可用屏幕空间可能并非整个物理屏幕。


宽度和高度是单独分类的,因此在任何时间点,应用都有两个窗口大小类别:宽度窗口大小类别和高度窗口大小类别。由于垂直滚动的普遍存在,可用宽度通常比可用高度更重要;因此,在这种情况下,您还需要使用宽度大小类别。
折叠状态
可折叠设备因尺寸各异且有铰链,因此可让您的应用适应更多情况。铰链可能会遮挡显示屏的一部分,导致该区域不适合显示内容;铰链也可能是分隔式的,这意味着设备展开时会有两个独立的实体显示屏。

此外,用户可能在铰链部分打开时查看内屏,从而根据折叠方向呈现不同的姿势:桌面姿势(水平折叠,如上图右侧所示)和图书姿势(竖直折叠)。
详细了解折叠状态和合页。
在实现支持可折叠设备的自适应布局时,需要考虑上述所有因素。
获取自适应信息
Material3 adaptive 库可让您轻松访问应用所运行窗口的相关信息。
- 向版本目录文件添加相应制品及其版本的条目:
gradle/libs.versions.toml
[versions]
material3Adaptive = "1.0.0"
[libraries]
androidx-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "material3Adaptive" }
- 在应用模块的 build 文件中,添加新的库依赖项,然后执行 Gradle 同步:
app/build.gradle.kts
dependencies {
implementation(libs.androidx.material3.adaptive)
}
现在,在任何可组合范围内,您都可以使用 currentWindowAdaptiveInfo() 获取 WindowAdaptiveInfo 对象,该对象包含当前窗口大小类别以及设备是否处于可折叠设备状态(例如桌面模式)等信息。
您现在可以在 MainActivity 中试用此功能。
- 在
ReplyTheme代码块内的onCreate()中,获取窗口自适应信息,并在Text可组合项中显示大小类别。您可以在ReplyApp()元素后添加此元素:
MainActivity.kt
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ReplyTheme {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
ReplyApp(
replyHomeUIState = uiState,
onEmailClick = viewModel::setSelectedEmail
)
val adaptiveInfo = currentWindowAdaptiveInfo()
val sizeClassText =
"${adaptiveInfo.windowSizeClass.windowWidthSizeClass}\n" +
"${adaptiveInfo.windowSizeClass.windowHeightSizeClass}"
Text(
text = sizeClassText,
color = Color.Magenta,
modifier = Modifier.padding(
WindowInsets.safeDrawing.asPaddingValues()
)
)
}
}
}
现在运行应用,您会看到窗口大小类打印在应用内容上方。您可以随意探索窗口自适应信息中提供的其他内容。之后,您可以移除此 Text,因为它会遮盖应用内容,并且在后续步骤中不需要用到。
4. 动态导航栏
现在,您将根据设备状态和尺寸的变化调整应用的导航,以便让应用更易于使用。
用户握持手机时,手指通常位于屏幕底部。当用户持握处于展开状态的可折叠设备或平板电脑时,手指通常会靠近两侧。用户应该能够浏览应用或发起与应用的互动,而无需将手置于极端位置或更改手部位置。
在设计应用并决定在布局中放置互动式界面元素的位置时,请考虑屏幕不同区域的人体工程学影响。
- 在持握设备时,哪些区域触及起来比较舒适?
- 哪些区域只能通过伸长手指来触及,这可能会带来不便?
- 哪些区域难以触及或距离用户握持设备的位置较远?
导航栏是用户最先互动的元素,其中包含与关键用户历程相关的重要操作,因此应放置在最容易触达的区域。Material 自适应库提供了多种组件,可帮助您根据设备的窗口大小类别来实现导航栏。
底部导航栏
底部导航栏非常适合较小的大小,因为我们会自然而然地持握设备,此时我们的拇指能够轻松触及底部导航栏的所有接触点。如果您的设备大小较小或可折叠设备处于较小的折叠状态,则使用此导航栏。

侧边导航栏
对于中等宽度的窗口大小,侧边导航栏非常适合单手操作,因为我们的拇指会自然而然地放在设备的侧面。您还可以将侧边导航栏与抽屉式导航栏搭配使用,以便显示更多信息。

抽屉式导航栏
抽屉式导航栏允许您轻松查看导航标签页的详细信息,而且便于在您使用平板电脑或更大的设备时访问。可用的抽屉式导航栏有两种:模态抽屉式导航栏和永久性抽屉式导航栏。
模态抽屉式导航栏
您可以针对紧凑到中等大小的手机和平板电脑使用模态抽屉式导航栏,因为这种导航栏可以作为叠加层在内容上方展开或隐藏。有时,此功能可与导航栏结合使用。

永久性抽屉式导航栏
您还可以针对大型平板电脑、Chromebook 和桌面设备使用永久性抽屉式导航栏作为固定导航方式。

实现动态导航
现在,您将根据设备状态和尺寸的变化而切换不同类型的导航栏。
目前,无论设备状态如何,应用始终会在屏幕内容下方显示 NavigationBar。您可以改用 Material NavigationSuiteScaffold 组件,根据当前窗口大小类别等信息自动在不同的导航组件之间切换。
- 通过更新版本目录和应用的 build 脚本来添加 Gradle 依赖项以获取此组件,然后执行 Gradle 同步:
gradle/libs.versions.toml
[versions]
material3AdaptiveNavSuite = "1.3.0"
[libraries]
androidx-material3-adaptive-navigation-suite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite", version.ref = "material3AdaptiveNavSuite" }
app/build.gradle.kts
dependencies {
implementation(libs.androidx.material3.adaptive.navigation.suite)
}
- 在
ReplyApp.kt中找到ReplyNavigationWrapper()可组合函数,并将Column及其内容替换为NavigationSuiteScaffold:
ReplyApp.kt
@Composable
private fun ReplyNavigationWrapperUI(
content: @Composable () -> Unit = {}
) {
var selectedDestination: ReplyDestination by remember {
mutableStateOf(ReplyDestination.Inbox)
}
NavigationSuiteScaffold(
navigationSuiteItems = {
ReplyDestination.entries.forEach {
item(
selected = it == selectedDestination,
onClick = { /*TODO update selection*/ },
icon = {
Icon(
imageVector = it.icon,
contentDescription = stringResource(it.labelRes)
)
},
label = {
Text(text = stringResource(it.labelRes))
},
)
}
}
) {
content()
}
}
navigationSuiteItems 实参是一个代码块,可让您使用 item() 函数添加项,类似于在 LazyColumn 中添加项。在尾随 lambda 中,此代码会调用作为实参传递给 ReplyNavigationWrapperUI() 的 content()。
在模拟器上运行应用,尝试在手机、可折叠设备和平板电脑之间更改尺寸,您会看到导航栏更改为侧边导航栏,然后再改回导航栏。
在非常宽的窗口中(例如横屏平板电脑上),您可能希望显示永久性抽屉式导航栏。NavigationSuiteScaffold 支持显示永久性抽屉,但当前任何 WindowWidthSizeClass 值中均未显示。不过,您只需稍作更改即可实现此目的。
- 在调用
NavigationSuiteScaffold之前添加以下代码:
ReplyApp.kt
@Composable
private fun ReplyNavigationWrapperUI(
content: @Composable () -> Unit = {}
) {
var selectedDestination: ReplyDestination by remember {
mutableStateOf(ReplyDestination.Inbox)
}
val windowSize = with(LocalDensity.current) {
currentWindowSize().toSize().toDpSize()
}
val layoutType = if (windowSize.width >= 1200.dp) {
NavigationSuiteType.NavigationDrawer
} else {
NavigationSuiteScaffoldDefaults.calculateFromAdaptiveInfo(
currentWindowAdaptiveInfo()
)
}
NavigationSuiteScaffold(
layoutType = layoutType,
...
) {
content()
}
}
此代码首先获取窗口大小,并使用 currentWindowSize() 和 LocalDensity.current 将其转换为 DP 单位,然后比较窗口宽度以确定导航界面的布局类型。如果窗口宽度至少为 1200.dp,则使用 NavigationSuiteType.NavigationDrawer。否则,系统会回退到默认计算方式。
当您在可调整大小的模拟器上再次运行该应用并尝试不同的类型时,您会发现,每当屏幕配置发生变化或您展开折叠设备时,导航栏都会更改为相应大小的适用类型。

恭喜,您已了解不同类型的导航栏,并能支持不同类型的窗口大小和状态!
在下一部分中,您将探索如何充分利用任何剩余的屏幕区域,而不是将相同列表项一直延伸到两边。
5. 屏幕空间使用
无论您是在小型平板电脑、展开的设备还是大型平板电脑上运行该应用,系统都会拉伸屏幕以填满剩余空间。您需要确保您能够充分利用屏幕空间以显示更多信息,例如在该应用中,应在同一页面中向用户显示电子邮件和会话。
Material 3 定义了三种规范布局,每种布局都具有适用于紧凑型、中等和扩展型窗口大小类的配置。列表详情规范布局非常适合此使用情形,并且在 Compose 中以 ListDetailPaneScaffold 的形式提供。
- 通过添加以下依赖项并执行 Gradle 同步来获取此组件:
gradle/libs.versions.toml
[libraries]
androidx-material3-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout", version.ref = "material3Adaptive" }
androidx-material3-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation", version.ref = "material3Adaptive" }
app/build.gradle.kts
dependencies {
implementation(libs.androidx.material3.adaptive.layout)
implementation(libs.androidx.material3.adaptive.navigation)
}
- 在
ReplyApp.kt中找到ReplyAppContent()可组合函数,该函数目前仅通过调用ReplyListPane()显示列表窗格。通过插入以下代码,将此实现替换为ListDetailPaneScaffold。由于这是一个实验性 API,您还需要向ReplyAppContent()函数添加@OptIn注解:
ReplyApp.kt
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun ReplyAppContent(
replyHomeUIState: ReplyHomeUIState,
onEmailClick: (Email) -> Unit,
) {
val navigator = rememberListDetailPaneScaffoldNavigator<Long>()
ListDetailPaneScaffold(
directive = navigator.scaffoldDirective,
value = navigator.scaffoldValue,
listPane = {
ReplyListPane(replyHomeUIState, onEmailClick)
},
detailPane = {
ReplyDetailPane(replyHomeUIState.emails.first())
}
)
}
此代码首先使用 rememberListDetailPaneNavigator 创建一个导航器。导航器可控制显示哪个窗格以及该窗格中应显示哪些内容,我们将在后面演示这一点。
当窗口宽度大小类别为较大时,ListDetailPaneScaffold 将显示两个窗格。否则,系统会根据为两个参数(即支架指令和支架值)提供的值显示一个窗格或另一个窗格。为了获得默认行为,此代码使用了支架指令和导航器提供的支架值。
其余必需参数是窗格的可组合 lambda。ReplyListPane() 和 ReplyDetailPane()(位于 ReplyListContent.kt 中)分别用于填充列表窗格和详情窗格的角色。ReplyDetailPane() 需要一个电子邮件实参,因此目前此代码使用 ReplyHomeUIState 中电子邮件列表中的第一个电子邮件。
运行应用,并将模拟器视图切换为可折叠设备或平板电脑(您可能还需要更改屏幕方向),以查看双窗格布局。这看起来比以前好多了!
现在,我们来介绍一下此界面的部分预期行为。当用户点按列表窗格中的电子邮件时,该电子邮件应显示在详情窗格中,同时显示所有回复。目前,应用不会跟踪选择了哪个电子邮件,点按相应项也不会执行任何操作。最好将此信息与其余界面状态一起保存在 ReplyHomeUIState 中。
- 打开
ReplyHomeViewModel.kt并找到ReplyHomeUIState数据类。为所选电子邮件添加一个属性,默认值为null:
ReplyHomeViewModel.kt
data class ReplyHomeUIState(
val emails : List<Email> = emptyList(),
val selectedEmail: Email? = null,
val loading: Boolean = false,
val error: String? = null
)
- 在同一文件中,
ReplyHomeViewModel具有一个setSelectedEmail()函数,当用户点按列表项时会调用该函数。修改此函数以复制界面状态并记录所选电子邮件地址:
ReplyHomeViewModel.kt
fun setSelectedEmail(email: Email) {
_uiState.update {
it.copy(selectedEmail = email)
}
}
需要考虑的是,在用户点按任何内容之前,如果所选电子邮件为 null,会发生什么情况。详细信息窗格中应显示哪些内容?您可以通过多种方式来处理这种情况,例如默认显示列表中的第一个项目。
- 在同一文件中,修改
observeEmails()函数。当电子邮件列表加载完毕后,如果之前的界面状态没有选定电子邮件,则将其设置为第一项:
ReplyHomeViewModel.kt
private fun observeEmails() {
viewModelScope.launch {
emailsRepository.getAllEmails()
.catch { ex ->
_uiState.value = ReplyHomeUIState(error = ex.message)
}
.collect { emails ->
val currentSelection = _uiState.value.selectedEmail
_uiState.value = ReplyHomeUIState(
emails = emails,
selectedEmail = currentSelection ?: emails.first()
)
}
}
}
- 返回到
ReplyApp.kt并使用所选电子邮件(如果有)填充详情窗格内容:
ReplyApp.kt
ListDetailPaneScaffold(
// ...
detailPane = {
if (replyHomeUIState.selectedEmail != null) {
ReplyDetailPane(replyHomeUIState.selectedEmail)
}
}
)
再次运行应用,将模拟器切换为平板电脑尺寸,然后查看点按列表项是否会更新详情窗格的内容。
当两个窗格都可见时,此功能运行良好,但当窗口只能显示一个窗格时,点按某个项目时似乎没有任何反应。尝试将模拟器视图切换为手机或竖向可折叠设备,并注意即使点按某个项,也只会显示列表窗格。这是因为,即使所选电子邮件已更新,ListDetailPaneScaffold 在这些配置中仍会将焦点保持在列表窗格中。
- 如需修复此问题,请插入以下代码作为传递给
ReplyListPane的 lambda:
ReplyApp.kt
ListDetailPaneScaffold(
// ...
listPane = {
ReplyListPane(
replyHomeUIState = replyHomeUIState,
onEmailClick = { email ->
onEmailClick(email)
navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, email.id)
}
)
},
// ...
)
此 lambda 使用之前创建的导航器在点击项时添加其他行为。它会调用传递给此函数的原始 lambda,然后还会调用 navigator.navigateTo() 来指定应显示哪个窗格。支架中的每个窗格都关联着一个角色,而详情窗格的角色为 ListDetailPaneScaffoldRole.Detail。在较小的窗口中,这会使应用看起来像已向前导航。
应用还需要处理用户从详情窗格中按返回按钮时发生的情况,而此行为会因显示的是一个窗格还是两个窗格而异。
- 添加以下代码,以支持返回导航。
ReplyApp.kt
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun ReplyAppContent(
replyHomeUIState: ReplyHomeUIState,
onEmailClick: (Email) -> Unit,
) {
val navigator = rememberListDetailPaneScaffoldNavigator<Long>()
BackHandler(navigator.canNavigateBack()) {
navigator.navigateBack()
}
ListDetailPaneScaffold(
directive = navigator.scaffoldDirective,
value = navigator.scaffoldValue,
listPane = {
AnimatedPane {
ReplyListPane(
replyHomeUIState = replyHomeUIState,
onEmailClick = { email ->
onEmailClick(email)
navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, email.id)
}
)
}
},
detailPane = {
AnimatedPane {
if (replyHomeUIState.selectedEmail != null) {
ReplyDetailPane(replyHomeUIState.selectedEmail)
}
}
}
)
}
导航器知道 ListDetailPaneScaffold 的完整状态、是否可以进行返回导航,以及在所有这些场景中应执行的操作。此代码会创建一个 BackHandler,每当导航器可以向后导航时,该 BackHandler 就会处于启用状态,并在 lambda 内调用 navigateBack()。此外,为了使窗格之间的过渡更加顺畅,每个窗格都封装在 AnimatedPane() 可组合项中。
在可调整大小的模拟器上,针对各种不同类型的设备再次运行该应用。您会发现,每当屏幕配置发生变化或您展开折叠设备时,导航栏和屏幕内容都会发生动态变化以响应设备状态变化。您还可以尝试点按列表窗格中的电子邮件,看看布局在不同屏幕上的表现,即并排显示两个窗格或在两个窗格之间平滑地切换。

恭喜!您已成功调整应用,使其能够适应各种类型的设备状态和大小。接下来,您可以尝试在可折叠设备、平板电脑或其他移动设备上运行该应用。
6. 恭喜
恭喜!您已成功完成此 Codelab,并学习了如何使用 Jetpack Compose 使应用具有自适应能力。
您学习了如何检查设备的大小和折叠状态,以及如何相应地更新应用的界面、导航栏和其他功能。此外,您还了解了自适应功能如何提升可单手操作性和用户体验。
后续操作
请查看 Compose 开发者在线课程中的其他 Codelab。
示例应用
- Compose 示例是多个应用的集合,这些应用都采用了 Codelab 中介绍的最佳实践。