使用 Jetpack Compose 构建自适应应用

1. 简介

在此 Codelab 中,您将学习如何使用 Jetpack Compose 构建适用于手机、平板电脑和可折叠设备的自适应应用,以及如何提升这些应用的可单手操作性。您还将学习使用 Material 3 组件和主题的最佳实践。

在深入了解这方面内容之前,我们有必要先了解一下自适应的含义。

适应性工具

应用的界面应能适应不同的窗口大小、屏幕方向和外形规格。自适应布局会根据可用的屏幕空间自动调整。这些调整包括简单的布局调整(以填满空间)、选择相应导航栏样式,以及完全更改布局(以利用额外的空间)。

如需了解详情,请参阅自适应设计

在此 Codelab 中,您将探索在使用 Jetpack Compose 时如何使用和看待自适应。您将构建一款名为 Reply 的应用,它将向您展示如何针对各种屏幕实现自适应,以及可自适应性和可单手操作性如何协同工作,以便为用户提供最佳体验。

学习内容

  • 如何使用 Jetpack Compose 设计适用于所有窗口大小的应用。
  • 如何使应用适用于不同的可折叠设备。
  • 如何使用不同类型的导航栏来实现更好的单手操作和无障碍功能。
  • 如何使用 Material 3 组件针对每种窗口大小提供最佳体验。

所需条件

您将在此 Codelab 中使用可调整大小的模拟器,该模拟器可让您在不同类型的设备和窗口大小之间切换。

可调整大小的模拟器,包含手机、可折叠设备的展开状态、平板电脑和桌面设备选项。

如果您不熟悉 Compose,建议您在完成此 Codelab 之前先完成“Jetpack Compose 基础知识”Codelab

构建内容

  • 一款名为 Reply 的交互式电子邮件客户端应用,该应用采用最佳实践来实现自适应设计、不同的 Material 导航和最佳的屏幕空间使用。

您将在此 Codelab 中实现的多设备支持的展示

2. 进行设置

如需获取此 Codelab 的代码,请从命令行克隆 GitHub 代码库:

git clone https://github.com/android/codelab-android-compose.git
cd codelab-android-compose/AdaptiveUiCodelab

或者,您也可以下载代码库 Zip 文件。

我们建议您从 main 分支中的代码着手,按照自己的节奏逐步完成此 Codelab。

在 Android Studio 中打开项目

  1. Welcome to Android Studio 窗口中,选择 c01826594f360d94.pngOpen an Existing Project
  2. 选择文件夹 <Download Location>/AdaptiveUiCodelab(提示:务必选择包含 build.gradleAdaptiveUiCodelab 目录)。
  3. Android Studio 导入项目后,请测试您能否运行 main 分支。

探索起始代码

main 分支代码包含 ui 软件包。您将使用该软件包中的以下文件:

  • MainActivity.kt - 用于启动应用的入口点 activity。
  • ReplyApp.kt - 包含主屏幕界面可组合项。
  • ReplyHomeViewModel.kt - 提供应用内容的数据和界面状态。
  • ReplyListContent.kt - 包含用于提供列表和详情屏幕的可组合项。

如果您在可调整大小的模拟器上运行该应用,并尝试采用不同的设备类型(例如手机或平板电脑),界面只会展开至给定空间,而不会充分利用屏幕空间或提供符合可单手操作性要求的人体工学体验。

手机上的初始屏幕

平板电脑上的初始伸展视图

您将更新该应用,以便充分利用屏幕空间、提高易用性,并提升整体用户体验。

3. 使应用具有自适应能力

本部分将介绍使应用具有自适应能力意味着什么,以及 Material 3 提供了哪些组件来简化这一点。此外,它还涵盖您要定位的屏幕和状态类型,包括手机、平板电脑、大型平板电脑和可折叠设备。

首先,您将了解窗口大小、折叠状态和不同类型的导航选项方面的基础知识。然后,您可以在应用中使用这些 API 来提高应用的适应性。

窗口大小

Android 设备形状各异且尺寸不一,从手机、可折叠设备到平板电脑和 ChromeOS 设备,不一而足。为了支持尽可能多的窗口大小,您的界面需要具备自适应能力。为了帮助您找到用于更改应用界面的正确阈值,我们定义了一些断点值,以帮助将设备分类为预定义的大小类别(较小、中等和较大),称为窗口大小类别。窗口大小类别是一组主观的视口断点,有助于您设计、开发并测试响应式和自适应布局。

这些类别是我们专门选择的,目的是平衡布局简单性,以便针对独特情形灵活地优化您的应用。窗口大小类别始终由应用的可用屏幕空间决定,而出于多任务处理或其他分屏目的,可用屏幕空间可能并非整个物理屏幕。

适用于较小宽度、中等宽度和较大宽度的 WindowWidthSizeClass。

适用于较小、中等和较大高度的 WindowHeightSizeClass。

宽度和高度是单独分类的,因此在任何时间点,应用都有两个窗口大小类别:宽度窗口大小类别和高度窗口大小类别。由于垂直滚动的普遍存在,可用宽度通常比可用高度更重要,因此在这种情况下,您还需要使用宽度大小类别。

折叠状态

由于可折叠设备的尺寸多样且有合页,因此应用可以适应更多情况。合页可能会遮挡部分显示屏,导致相应区域不适合显示内容;它们也可能是分开的,这意味着当设备展开时有两个独立的物理显示屏。

可折叠设备折叠状态:平展和半开

此外,当合页部分打开时,用户可能正在看内屏,从而根据折叠边的方向呈现不同的物理折叠状态:桌面折叠状态(水平折叠边,如上图右侧所示)和图书折叠状态(垂直折叠边)。

详细了解折叠状态和合页

在实现支持可折叠设备的自适应布局时,所有这些都是需要考虑的因素。

获取自适应信息

借助 Material3 adaptive 库,您可以方便地访问应用运行时所在窗口的相关信息。

  1. 将此工件及其版本的条目添加到版本目录文件中:

gradle/libs.versions.toml

[versions]
material3Adaptive = "1.0.0-beta01"

[libraries]
androidx-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "material3Adaptive" }
  1. 在应用模块的 build 文件中,添加新的库依赖项,然后执行 Gradle 同步:

app/build.gradle.kts

dependencies {

    implementation(libs.androidx.material3.adaptive)
}

现在,在任何可组合作用域中,您都可以使用 currentWindowAdaptiveInfo() 获取 WindowAdaptiveInfo 对象,该对象包含当前的窗口大小类别以及设备是否处于可折叠状态(如桌面折叠状态)。

您现在可以在 MainActivity 中尝试此操作。

  1. onCreate() 中的 ReplyTheme 代码块内,获取窗口自适应信息,并在 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 组件,根据当前窗口大小类别等信息在不同导航组件之间自动切换。

  1. 添加 Gradle 依赖项以获取此组件,方法是更新版本目录和应用的 build 脚本,然后执行 Gradle 同步:

gradle/libs.versions.toml

[versions]
material3AdaptiveNavSuite = "1.3.0-beta01"

[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)
}
  1. 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 值中。不过,您只需稍作更改即可实现。

  1. 在对 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 定义了三种规范布局,每种布局都有适用于较小、中等和较大窗口大小类别的配置。List Detail 规范布局非常适合此用例,在 Compose 中以 ListDetailPaneScaffold 的形式提供。

  1. 添加以下依赖项并执行 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)
}
  1. 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 会显示两个窗格。否则,系统将根据为 scaffold 指令和 Scaffold 值提供的值显示一个窗格或另一个窗格。为了获得默认行为,此代码使用 scaffold 指令以及导航器提供的 Scaffold 值。

其余必需参数是窗格的可组合 lambda。ReplyListPane()ReplyDetailPane()(可在 ReplyListContent.kt 中找到)分别用于填充列表窗格和详情窗格的角色。ReplyDetailPane() 需要电子邮件参数,因此,此代码目前使用 ReplyHomeUIState 中电子邮件列表中的第一封电子邮件。

运行应用并将模拟器视图切换到可折叠设备或平板电脑模式(您可能还需要更改屏幕方向),才能看到双窗格布局。这看起来比之前好多了!

现在,我们来介绍此屏幕的一些预期行为。当用户点按列表窗格中的某封电子邮件时,该邮件应该与所有回复一起显示在详情窗格中。目前,该应用不会跟踪用户选择了哪封电子邮件,并且点按某项内容并不会执行任何操作。最好将这些信息保存在 ReplyHomeUIState 中的其余界面状态中。

  1. 打开 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
)
  1. 在同一个文件中,ReplyHomeViewModel 具有 setSelectedEmail() 函数,当用户点按列表项时,系统会调用该函数。修改此函数,以复制界面状态并记录所选电子邮件:

ReplyHomeViewModel.kt

fun setSelectedEmail(email: Email) {
    _uiState.update {
        it.copy(selectedEmail = email)
    }
}

需要考虑的是,在用户点按任意一项且所选电子邮件地址为 null 之前发生了什么。详细信息窗格中应显示什么内容?处理这种情况有多种方式,如默认显示列表中的第一项。

  1. 在同一文件中,修改 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()
                )
            }
    }
}
  1. 返回 ReplyApp.kt,然后使用所选电子邮件地址(如果有)填充详细信息窗格内容:

ReplyApp.kt

ListDetailPaneScaffold(
    // ...
    detailPane = {
        if (replyHomeUIState.selectedEmail != null) {
            ReplyDetailPane(replyHomeUIState.selectedEmail)
        }
    }
)

再次运行应用,并将模拟器切换到平板电脑尺寸,您可以看到点按列表项会更新详情窗格中的内容。

这在两个窗格均可见时效果非常好,但当窗口仅有空间显示一个窗格时,当您点按某项时,看起来好像什么都没有发生。尝试以竖屏模式将模拟器视图切换到手机或可折叠设备,您会发现在点按某个项后,系统仅会显示列表窗格。这是因为,即使所选电子邮件已更新,在这些配置中,ListDetailPaneScaffold 仍会专注于列表窗格。

  1. 如需解决此问题,请插入以下代码作为传递给 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。在较小的窗口中,这会呈现应用向前导航的外观。

应用还需要处理用户从详情窗格中按下返回按钮时发生的情况,并且此行为会因只显示一个窗格还是两个窗格而有所不同。

  1. 添加以下代码,支持返回导航。

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,每当导航器可以返回,且在 lambda 内调用 navigateBack() 时,就会启用该 BackHandler。此外,为了让各窗格之间的过渡更加流畅,每个窗格都封装在 AnimatedPane() 可组合项中。

在可调整大小的模拟器上,针对各种不同类型的设备再次运行应用。请注意,每当屏幕配置发生变化或您展开折叠设备时,导航和屏幕内容都会动态变化以响应设备状态变化。您还可以尝试点按列表窗格中的电子邮件,看看布局在不同屏幕上的表现:并排显示两个窗格或在两个窗格之间流畅显示动画效果。

显示针对不同尺寸设备进行的自适应更改。

恭喜!您已成功调整应用,使其能够适应各种类型的设备状态和大小。接下来,您可以尝试在可折叠设备、平板电脑或其他移动设备上运行该应用。

6. 恭喜

恭喜!您已成功完成此 Codelab,并学习了如何使用 Jetpack Compose 使应用具有自适应能力。

您学习了如何检查设备的大小和折叠状态,以及如何相应地更新应用的界面、导航栏和其他功能。您还了解了自适应功能如何提高可单手操作性并改善用户体验。

后续操作

请查看 Compose 开发者在线课程中的其他 Codelab。

示例应用

  • Compose 示例是许多应用的集合,这些应用都采用了 Codelab 中介绍的最佳实践。

参考文档