使用 Jetpack Compose 构建自适应应用

1. 简介

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

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

适应性工具

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

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

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

学习内容

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

所需条件

  • 最新的稳定版 Android Studio
  • Android 13 可调整大小的虚拟设备
  • 了解 Kotlin。
  • 对 Compose(如 @Composable 注解)有基本的了解。
  • 基本熟悉 Compose 布局(例如 RowColumn)。
  • 基本熟悉修饰符(例如 Modifier.padding())。

在此 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"

[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. 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 组件根据当前窗口大小类别等信息在不同的导航组件之间自动切换。

  1. 通过更新版本目录和应用的 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)
}
  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 定义了三个规范布局,每个布局都针对较小、中等和较大窗口大小类别提供了配置。列表详情规范布局非常适合此用例,在 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 将显示两个窗格。否则,它将根据为两个参数(即框架指令和框架值)提供的值显示一个窗格或另一个窗格。为了获取默认行为,此代码使用了导航器提供的框架指令和框架值。

其余必需参数是窗格的可组合 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 中介绍的最佳实践。

参考文档