构建具有动态导航栏的自适应应用

1. 简介

在 Android 平台中开发应用的一大优势是,您有很大的机会吸引不同类型的设备(例如穿戴式设备、可折叠设备、平板电脑、桌面设备,甚至是电视)上的用户。在使用某款应用时,用户可能也会希望在大屏设备上使用该应用,以充分利用更大的屏幕。Android 用户越来越多地在屏幕尺寸各异的多种设备上使用应用,并且希望在所有设备上都能获得优质的用户体验。

到目前为止,您已经了解了如何打造主要适合移动设备的应用。在此 Codelab 中,您将学习如何促进应用转型,使其能够适应其他屏幕尺寸。您将使用自适应导航布局模式,这类模式非常美观,同时适用于移动设备和大屏设备,例如可折叠设备、平板电脑和桌面设备。

前提条件

  • 熟悉 Kotlin 编程,包括类、函数和条件
  • 熟悉如何使用 ViewModel
  • 熟悉如何创建 Composables 函数
  • 拥有使用 Jetpack Compose 构建布局的经验
  • 拥有在设备或模拟器上运行应用的经验

学习内容

  • 如何在没有导航图的情况下为简单的应用创建屏幕之间的导航
  • 如何使用 Jetpack Compose 创建自适应导航布局
  • 如何创建自定义返回处理程序

构建内容

  • 您将在现有的 Reply 应用中实现动态导航栏,使其布局能够适应所有屏幕尺寸

完成后的效果将如下图所示:

​​ 此 Codelab 结束时的 Reply 应用图示,其中抽屉式导航栏显示在左侧。该抽屉式导航栏列出了 4 个可供用户选择的标签页:“Inbox”“Sent”“Drafts”和“Spam”。抽屉式导航栏右侧显示了一系列示例电子邮件。

所需条件

  • 可连接到互联网的计算机、网络浏览器和 Android Studio
  • 能够访问 GitHub

2. 应用概览

Reply 应用简介

Reply 是一款多屏幕应用,类似于电子邮件客户端

Reply 应用在手机模式下的界面,显示了一系列示例电子邮件,以供用户查阅。屏幕底部有 4 个图标,分别对应“Inbox”“Sent”“Drafts”和“Spam”。

该应用包含 4 个不同类别,分别显示在不同的标签页中,即“Inbox”“Sent”“Drafts”和“Spam”。

下载起始代码

在 Android Studio 中,打开 basic-android-kotlin-compose-training-reply-app 文件夹。

  1. 进入为此项目提供的 GitHub 代码库页面。
  2. 验证分支名称是否与此 Codelab 中指定的分支名称一致。例如,在以下屏幕截图中,分支名称为 main

1e4c0d2c081a8fd2.png

  1. 在项目的 GitHub 页面上,点击 Code 按钮,以打开一个弹出式窗口。

1debcf330fd04c7b.png

  1. 在弹出式窗口中,点击 Download ZIP 按钮,将项目保存到计算机上。等待下载完成。
  2. 在计算机上找到该文件(可能在 Downloads 文件夹中)。
  3. 双击 ZIP 文件进行解压缩。系统将创建一个包含项目文件的新文件夹。

在 Android Studio 中打开项目

  1. 启动 Android Studio。
  2. Welcome to Android Studio 窗口中,点击 Open

d8e9dbdeafe9038a.png

注意:如果 Android Studio 已经打开,则改为依次选择 File > Open 菜单选项。

8d1fda7396afe8e5.png

  1. 在文件浏览器中,转到解压缩的项目文件夹所在的位置(很可能在 Downloads 文件夹中)。
  2. 双击该项目文件夹。
  3. 等待 Android Studio 打开项目。
  4. 点击 Run 按钮 8de56cba7583251f.png 以构建并运行应用。请确保该应用按预期构建。

3. 起始代码演示

Reply 应用中的重要目录

Reply 应用文件目录显示了两个已展开的子目录:“data”和“ui”。在 ui 目录中,MainActivity.kt 处于选中状态。MainActivity.kt 显示在内容列表的末尾处。

Reply 应用项目的数据和界面层分到了不同的目录中。ReplyViewModelReplyUiState 和其他可组合项位于 ui 目录中。用于定义数据层的 dataenum 类以及数据提供程序类均位于 data 目录中。

Reply 应用中的数据初始化

Reply 应用通过 ReplyViewModel 中的 initilizeUIState() 方法初始化数据,该方法在 init 函数中执行。

ReplyViewModel.kt

...
   init {
        initializeUIState()
    }

   private fun initializeUIState() {
        var mailboxes: Map<MailboxType, List<Email>> =
            LocalEmailsDataProvider.allEmails.groupBy { it.mailbox }
        _uiState.value =
            ReplyUiState(
                mailboxes = mailboxes,
                currentSelectedEmail = mailboxes[MailboxType.Inbox]?.get(0)
                    ?: LocalEmailsDataProvider.defaultEmail
            )
    }
...

屏幕级可组合项

与其他应用一样,Reply 应用使用 ReplyApp 可组合项作为主要可组合项,viewModeluiState 会在其中声明。各种 viewModel 函数也会作为 ReplyHomeScreen 可组合项的 lambda 参数进行传递。

ReplyApp.kt

...
@Composable
fun ReplyApp(modifier: Modifier = Modifier) {
    val viewModel: ReplyViewModel = viewModel()
    val replyUiState = viewModel.uiState.collectAsState().value

    ReplyHomeScreen(
        replyUiState = replyUiState,
        onTabPressed = { mailboxType: MailboxType ->
            viewModel.updateCurrentMailbox(mailboxType = mailboxType)
            viewModel.resetHomeScreenStates()
        },
        onEmailCardPressed = { email: Email ->
            viewModel.updateDetailsScreenStates(
                email = email
            )
        },
        onDetailScreenBackPressed = {
            viewModel.resetHomeScreenStates()
        },
        modifier = modifier
    )
}

其他可组合项

  • ReplyHomeScreen.kt:包含主屏幕的屏幕可组合项,包括导航元素。
  • ReplyHomeContent.kt:包含的可组合项用于定义主屏幕的更详细的可组合项。
  • ReplyDetailsScreen.kt:包含屏幕可组合项和适用于详情屏幕的较小可组合项。

在继续学习此 Codelab 的下一部分之前,您可以详细查看每个文件,以便更好地了解可组合项。

4. 在没有导航图的情况下更改屏幕

在之前的在线课程中,您学习了如何使用 NavHostController 类从一个屏幕导航到另一个屏幕。借助 Compose,您还可以通过利用运行时可变状态,通过简单的条件语句更改屏幕。这在 Reply 应用等小型应用中尤为有用,在此类应用中,您只需要在两个屏幕之间进行切换。

在状态发生变化时更改屏幕

在 Compose 中,当状态发生变化时,屏幕会重组。您可以使用简单的条件更改屏幕,以响应状态的变化。

您将使用条件,以在用户位于主屏幕上时,显示主屏幕上的内容,在用户不位于主屏幕上时,显示详情屏幕上的内容。

通过完成以下步骤来修改 Reply 应用,以允许在状态发生变化时更改屏幕:

  1. 在 Android Studio 中打开起始代码。
  2. ReplyHomeScreen.ktReplyHomeScreen 可组合项中,针对 replyUiState 对象的 isShowingHomepage 属性为 true 的情况,使用 if 语句封装 ReplyAppContent 可组合项。

ReplyHomeScreen.kt

@Composable
fun ReplyHomeScreen(
    replyUiState: ReplyUiState,
    onTabPressed: (MailboxType) -> Unit = {},
    onEmailCardPressed: (Int) -> Unit = {},
    onDetailScreenBackPressed: () -> Unit = {},
    modifier: Modifier = Modifier
) {

...
    if (replyUiState.isShowingHomepage) {
        ReplyAppContent(
            replyUiState = replyUiState,
            onTabPressed = onTabPressed,
            onEmailCardPressed = onEmailCardPressed,
            navigationItemContentList = navigationItemContentList,
            modifier = modifier

        )
    }
}

现在,您必须通过显示详情屏幕,将用户不在主屏幕上的情况纳入考虑。

  1. 添加一个正文中包含 ReplyDetailsScreen 可组合项的 else 分支。将 replyUIStateonDetailScreenBackPressedmodifier 添加为 ReplyDetailsScreen 可组合项的参数。

ReplyHomeScreen.kt

@Composable
fun ReplyHomeScreen(
    replyUiState: ReplyUiState,
    onTabPressed: (MailboxType) -> Unit = {},
    onEmailCardPressed: (Int) -> Unit = {},
    onDetailScreenBackPressed: () -> Unit = {},
    modifier: Modifier = Modifier
) {

...

    if (replyUiState.isShowingHomepage) {
        ReplyAppContent(
            replyUiState = replyUiState,
            onTabPressed = onTabPressed,
            onEmailCardPressed = onEmailCardPressed,
            navigationItemContentList = navigationItemContentList,
            modifier = modifier

        )
    } else {
        ReplyDetailsScreen(
            replyUiState = replyUiState,
            onBackPressed = onDetailScreenBackPressed,
            modifier = modifier
        )
    }
}

replyUiState 对象是状态对象。因此,当 replyUiState 对象的 isShowingHomepage 属性发生变化时,系统会重组 ReplyHomeScreen 可组合项,并在运行时重新评估 if/else 语句。此方法支持在不使用 NavHostController 类的情况下,在不同屏幕之间导航。

手机模拟器上的 Reply 应用的动画图示,显示了从主屏幕到详情页面的屏幕更改。主屏幕上显示了一系列电子邮件,屏幕底部有 4 个邮件图标(“Inbox”“Sent”“Drafts”和“Spam”)。详情页面上显示了一封示例电子邮件的全部内容,内容下方是“Reply”和“Reply All”按钮。

创建自定义返回处理程序

使用 NavHost 可组合项在屏幕间切换的一个好处是,之前屏幕的方向会保存到返回堆栈中。借助这些已保存的屏幕,系统返回按钮可在调用时轻松导航回上一个屏幕。由于 Reply 应用不使用 NavHost,因此您必须添加代码,手动处理返回按钮。接下来,您将处理此事宜。

完成以下步骤,以便在 Reply 应用中创建自定义返回处理程序:

  1. ReplyDetailsScreen 可组合项的第一行中,添加一个 BackHandler 可组合项。
  2. BackHandler 可组合项的正文中,调用 onBackPressed() 函数。

ReplyDetailsScreen.kt

...
import androidx.activity.compose.BackHandler
...
@Composable
fun ReplyDetailsScreen(
    replyUiState: ReplyUiState,
    modifier: Modifier = Modifier,
    onBackPressed: () -> Unit = {},
) {
    BackHandler {
        onBackPressed()
    }
...

5. 在大屏设备上运行应用

使用可调整大小的模拟器来检查应用

若要打造易于使用的应用,开发者需要了解其用户在各类设备上的体验。因此,从开发流程之初,您就必须针对各类设备测试应用。

您可以使用屏幕尺寸各异的多个模拟器来实现此目标。不过,这样做可能会很麻烦,尤其是在同时针对多种屏幕尺寸进行构建时。您可能还需要测试正在运行的应用会如何响应屏幕尺寸的变化,例如屏幕方向变化、桌面设备中的窗口大小变化以及可折叠设备上的折叠状态变化。

Android Studio 中推出了可调整大小的模拟器,可帮助您测试这些情形。

完成以下步骤,以便设置可调整大小的模拟器:

  1. 确保您运行的是 Android Studio Chipmunk | 2021.2.1 或更高版本。
  2. 在 Android Studio 中,依次选择 Tools > Device Manager

“Tools”菜单中显示了一系列选项。位于列表中间的“Device Manager”处于选中状态。

  1. Device Manager 中,点击 Create device“Device Manager”工具栏中显示了两个菜单选项:“Virtual”和“Physical”。这些选项下方有一个“Create Device”按钮。
  2. 依次选择 Phone 类别和 Resizable (Experimental) 设备。
  3. 点击 Next

“Device Manager”窗口显示了一条选择设备定义的提示。系统显示了一系列选项,这些选项上面有一个搜索字段。类别“Phone”和设备定义名称“Resizable (Experimental)”处于选中状态。

  1. 选择 API Level 33
  2. 点击 Next

“Virtual Device Configuration”窗口显示了一条选择系统映像的提示。Tiramisu API 处于选中状态。

  1. 为新的 Android 虚拟设备命名。
  2. 点击 Finish

系统显示了 Android 虚拟设备 (AVD) 中的“Virtual Configration”屏幕。配置屏幕中包含一个用于输入 AVD 名称的文本字段。名称字段下方是一系列设备选项,包括设备定义(“Resizable [Experimental]”)、系统映像(“Tiramisu”)和屏幕方向(“Portrait”屏幕方向默认处于选中状态)。“Change”按钮显示在设备定义和系统映像信息的右侧,而“Landscape”选项显示在处于选中状态的“Portrait”屏幕方向选项的右侧。右下角有 4 个按钮:“Cancel”“Previous”“Next”(灰显,无法选择)和“Finish”。

在大屏模拟器上运行应用

现在,您已经设置了可调整大小的模拟器,接下来我们来看看应用在大屏幕上的呈现效果。

  1. 在可调整大小的模拟器上运行应用。
  2. 选择 Tablet 作为显示模式。

一个可调整大小的模拟器,显示了手机屏幕上的 Reply 应用。该应用显示了一系列邮件,屏幕底部有 4 个图标:“Inbox”“Sent”“Drafts”和“Spam”。

  1. 在“Tablet”模式下,以横屏模式查看应用。

一个可调整大小的模拟器,显示了平板电脑屏幕上的 Reply 应用,其中正文内容处于拉伸状态。屏幕底部显示了“Inbox”“Sent”“Drafts”和“Spam”的图标。

请注意,该应用在平板电脑屏幕上以水平拉伸状态显示。尽管这种屏幕方向在功能上没问题,但可能无法充分利用大屏幕的屏幕空间。下面我们来解决这个问题。

专为大屏幕而设计

当看到这款应用在平板电脑上的呈现效果时,您的第一感觉是,这款应用设计不合理,不太有吸引力。这完全正确:此布局不适用于大屏幕。

在针对大屏幕(例如平板电脑和可折叠设备)进行设计时,您必须考虑用户工效学以及用户的手指与屏幕之间的距离。使用移动设备时,用户的手指可以轻松地触及大部分屏幕;互动元素(例如按钮和导航元素)的位置则不那么重要。但是,对于大屏幕,将关键的互动元素放在屏幕中间可能会使其难以触及。

正如您在 Reply 应用中看到的那样,针对大屏幕设计不仅仅是拉伸或放大界面元素,使其适配屏幕。这是一次机会,让您能够利用较大的屏幕空间来为用户打造不同的体验。例如,您可以在同一屏幕上添加其他布局,让用户无需导航到其他屏幕,或者实现多任务处理功能。

Reply 应用在主屏幕上显示了详情屏幕以及抽屉式导航栏和电子邮件列表。示例电子邮件显示在电子邮件列表的右侧。示例电子邮件下方显示了“Reply”和“Reply All”按钮。

这种设计可以提高用户的工作效率,提升互动度。不过,在部署此设计之前,您必须先了解如何针对不同的屏幕尺寸创建不同的布局。

6. 让布局适应不同的屏幕尺寸

什么是断点?

您可能想知道如何为同一应用显示不同的布局。简单点回答就是,针对不同的状态使用条件,就像您在此 Codelab 开头时所做的那样。

如要构建自适应应用,您需要根据屏幕尺寸更改布局。布局发生更改的测量点称为“断点”。Material Design 创建了一个可以涵盖大多数 Android 屏幕的主观断点范围

此表格显示了不同设备类型和设置的断点范围(以 dp 为单位)。0 至 599 dp 适用于处于竖屏模式的手机、处于横屏模式的手机、较小窗口大小、列数为 4 以及最小外边距为 8 的情况。600 至 839 dp 适用于处于竖屏模式或横屏模式的可折叠设备和小型平板电脑、中等窗口大小类别、列数为 12 以及最小外边距为 12 的情况。840 dp 或更高适用于处于竖屏模式或横屏模式的大型平板电脑、较大窗口大小类别、列数为 12 以及最小外边距为 32 的情况。表格备注指明,外边距和间距较为灵活,尺寸不必相等;处于横屏模式的手机可视为一种例外情况,仍适用于 0 至 599 dp 的断点范围。

根据此断点范围表格,假设您的应用目前在屏幕尺寸小于 600 dp 的设备上运行,则应显示移动设备布局。

使用窗口大小类别

为 Compose 引入的 WindowSizeClass API 简化了 Material Design 断点的实现。

窗口大小类别针对宽度和高度引入了三种尺寸:较小、中等和较大。

此图表显示基于宽度的窗口大小类别。 此图表显示基于高度的窗口大小类别。

完成以下步骤,在 Reply 应用中实现 WindowSizeClass API:

  1. material3-window-size-class 依赖项添加到模块 build.gradle 文件中。

build.gradle

...
dependencies {
...
"androidx.compose.material3:material3-window-size-class:$material3_version"
...
  1. 添加依赖项后,点击 Sync Now 以同步 Gradle。

用于选择不同的 .kt 和 .gradle 文件的标签页下方显示了“Sync Now”按钮。“Sync Now”按钮的右侧是一个名为“Ignore these changes”的按钮。

确保 build.grade 文件最新后,您现在可以创建一个变量,以便在任意给定时间存储应用窗口的大小。

  1. MainActivity.kt 文件的 onCreate() 函数中,将参数中传入 this 上下文的 calculateWindowSizeClass 方法分配给名为 windowSize 的变量。
  2. 导入相应的 calculateWindowSizeClass 软件包。

MainActivity.kt

...
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass

...

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            ReplyTheme {
                val windowSize = calculateWindowSizeClass(this)
                ReplyApp()

...
  1. 可以注意到 calculateWindowSizeClass 语法有一条红色下划线,显示了红色灯泡。点击 windowSize 变量左侧的红色灯泡,然后选择 Opt in for ‘ExperimentalMaterial3WindowSizeClassApi' on ‘onCreate',以在 onCreate() 方法上方创建注解。

在代码中,“val windowSize = calculateWindowSizeClass(this)”一行处于选中状态,代码行的左侧显示了红色灯泡图标。选中的灯泡下方是一系列用于修正错误的选项,其中“Opt in for 'ExperimentalMaterial3WindowSizeClassApi' on 'onCreate'”处于选中状态。

您可以在 MainActivity.kt 中使用 WindowWidthSizeClass 变量来确定要在各种可组合项中显示的布局。下面我们来准备 ReplyApp 可组合项,以接收此值。

  1. ReplyApp.kt 文件中,修改 ReplyApp 可组合项以接受 WindowWidthSizeClass 作为参数,并导入相应的软件包。

ReplyApp.kt

...
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
...

@Composable
fun ReplyApp(
    windowSize: WindowWidthSizeClass,
    modifier: Modifier = Modifier
) {
...
  1. windowSize 变量传递给 MainActivity.kt 文件的 onCreate() 方法中的 ReplyApp 组件。

MainActivity.kt

...
         setContent {
            ReplyTheme {
                val windowSize = calculateWindowSizeClass(this)
                ReplyApp(
                    windowSize = windowSize.widthSizeClass
                )
...

此外,您还需要针对 windowSize 参数更新应用的预览。

  1. WindowWidthSizeClass.Compact 作为 windowSize 参数传递给预览组件的 ReplyApp 可组合项,并导入相应的软件包。

MainActivity.kt

...
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
...

@Preview(showBackground = true)
@Composable
fun ReplyAppPreview() {
    ReplyTheme {
        ReplyApp(
            windowSize = WindowWidthSizeClass.Compact,
        )
    }
}
  1. 如要根据屏幕尺寸更改应用布局,请基于 WindowWidthSizeClass 值在 ReplyApp 可组合项中添加 when 语句。

ReplyApp.kt

...

@Composable
fun ReplyApp(
    windowSize: WindowWidthSizeClass,
    modifier: Modifier = Modifier
) {
    val viewModel: ReplyViewModel = viewModel()
    val replyUiState = viewModel.uiState.collectAsState().value

    when (windowSize) {
        WindowWidthSizeClass.Compact -> {
        }
        WindowWidthSizeClass.Medium -> {
        }
        WindowWidthSizeClass.Expanded -> {
        }
        else -> {
        }
    }
...

现在,您已经为使用 WindowSizeClass 值更改应用中的布局奠定了基础。下一步是确定应用在不同屏幕尺寸上的呈现方式。

7. 实现自适应导航布局

实现自适应界面导航

目前,底部导航栏适用于所有屏幕尺寸。

Reply 应用的底部导航栏。

如前所述,此导航元素设计不太合理,因为用户会发现在较大的屏幕上很难触及这些基本的导航元素。幸运的是,在响应式界面的导航中针对不同的窗口大小类别,提供了不同的导航元素模式建议。对于 Reply 应用,您可以实现以下元素:

表格列出了窗口大小类别以及相应显示的一些项。“较小宽度”可显示“底部导航栏”。“中等宽度”可显示“侧边导航栏”。“较大宽度”可显示“持续存在的抽屉式导航栏(前缘)”。

侧边导航栏Material Design 推出的又一个导航组件,它支持用于从应用的一侧访问主要目标页面的较小导航选项。

Reply 应用中的示例侧边导航栏垂直显示了 4 个图标:“Inbox”“Sent”“Drafts”和“Spam”。

同样,持续存在的/永久性抽屉式导航栏Material Design 构建,是针对较大屏幕提供工效学访问设计的另一种选择。

Reply 应用中的永久性抽屉式导航栏垂直列出了 4 个标签页,每个标签页均有相应图标和名称:“Inbox”“Sent”“Drafts”和“Spam”。

实现抽屉式导航栏

如要为较大的屏幕创建抽屉式导航栏,您可以使用 navigationType 参数。为此,请完成以下步骤:

  1. 为了表示不同类型的导航元素,请在 ui 目录下的新软件包 utils 中创建一个新文件 WindowStateUtils.kt
  2. 添加一个 Enum 类,以表示不同类型的导航元素。

WindowStateUtils.kt

package com.example.reply.ui.utils

enum class ReplyNavigationType {
    BOTTOM_NAVIGATION, NAVIGATION_RAIL, PERMANENT_NAVIGATION_DRAWER
}

为了成功实现抽屉式导航栏,您需要根据应用的窗口大小来确定导航类型。

  1. ReplyApp 可组合项中,创建一个 navigationType 变量,并根据 when 语句中的屏幕尺寸,为其分配适当的 ReplyNavigationType 值。

ReplyApp.kt

...
import com.example.reply.ui.utils.ReplyNavigationType
...
    when (windowSize) {
        WindowWidthSizeClass.Compact -> {
            navigationType = ReplyNavigationType.BOTTOM_NAVIGATION
        }
        WindowWidthSizeClass.Medium -> {
            navigationType = ReplyNavigationType.NAVIGATION_RAIL
        }
        WindowWidthSizeClass.Expanded -> {
            navigationType = ReplyNavigationType.PERMANENT_NAVIGATION_DRAWER
        }
        else -> {
            navigationType = ReplyNavigationType.BOTTOM_NAVIGATION
        }
    }
...

您可以在 ReplyHomeScreen 可组合项中使用 navigationType 值。为此,您可以使之成为可组合项的参数。

  1. ReplyHomeScreen 可组合项中,将 navigationType 添加为参数。

ReplyHomeScreen.kt

...
@Composable
fun ReplyHomeScreen(
    navigationType: ReplyNavigationType,
    replyUiState: ReplyUiState,
    onTabPressed: (MailboxType) -> Unit = {},
    onEmailCardPressed: (Email) -> Unit = {},
    onDetailScreenBackPressed: () -> Unit = {},
    modifier: Modifier = Modifier
)

...

  1. navigationType 传入 ReplyHomeScreen 可组合项。

ReplyApp.kt

...
   ReplyHomeScreen(
        navigationType = navigationType,
        replyUiState = replyUiState,
        onTabPressed = { mailboxType: MailboxType ->
            viewModel.updateCurrentMailbox(mailboxType = mailboxType)
            viewModel.resetHomeScreenStates()
        },
        onEmailCardPressed = { email: Email ->
            viewModel.updateDetailsScreenStates(
                email = email
            )
        },
        onDetailScreenBackPressed = {
            viewModel.resetHomeScreenStates()
        },
        modifier = modifier
    )
...

接下来,您可以创建一个分支,以便当用户在较大屏幕上打开应用并位于主屏幕时,看到带有抽屉式导航栏的应用内容。

  1. ReplyHomeScreen 可组合项正文中,为 navigationType == ReplyNavigationType.PERMANENT_NAVIGATION_DRAWER && replyUiState.isShowingHomepage 条件添加 if 语句。

ReplyHomeScreen.kt

import androidx.compose.material3.PermanentNavigationDrawer
...
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReplyHomeScreen(
    navigationType: ReplyNavigationType,
    replyUiState: ReplyUiState,
    onTabPressed: (MailboxType) -> Unit = {},
    onEmailCardPressed: (Email) -> Unit = {},
    onDetailScreenBackPressed: () -> Unit = {},
    modifier: Modifier = Modifier
) {
...
    if (navigationType == ReplyNavigationType.PERMANENT_NAVIGATION_DRAWER
        && replyUiState.isShowingHomepage
    ) {
    }

    if (replyUiState.isShowingHomepage) {
            ReplyAppContent(
                replyUiState = replyUiState,
...
  1. 如要创建永久性抽屉式导航栏,请在 if 语句的正文中创建 PermanentNavigationDrawer 可组合项,并将 NavigationDrawerContent 可组合项添加为 drawerContent 参数的输入。
  2. ReplyAppContent 可组合项添加为 PermanentNavigationDrawer 的 final lambda 参数。

ReplyHomeScreen.kt

...
    if (navigationType == ReplyNavigationType.PERMANENT_NAVIGATION_DRAWER
        && replyUiState.isShowingHomepage
    ) {
        PermanentNavigationDrawer(
            drawerContent = {
                NavigationDrawerContent(
                    selectedDestination = replyUiState.currentMailbox,
                    onTabPressed = onTabPressed,
                    navigationItemContentList = navigationItemContentList
                )
            }
        ) {
            ReplyAppContent(
                replyUiState = replyUiState,
                onTabPressed = onTabPressed,
                onEmailCardPressed = onEmailCardPressed,
                navigationItemContentList = navigationItemContentList,
                modifier = modifier

            )
        }
    }

...
  1. 添加一个 else 分支,该分支使用之前的可组合项正文,以针对较大屏幕以外的屏幕维护之前的分支。

ReplyHomeScreen.kt

...
    if (navigationType == ReplyNavigationType.PERMANENT_NAVIGATION_DRAWER
        && replyUiState.isShowingHomepage
    ) {
        PermanentNavigationDrawer(
            drawerContent = {
                NavigationDrawerContent(
                    selectedDestination = replyUiState.currentMailbox,
                    onTabPressed = onTabPressed,
                    navigationItemContentList = navigationItemContentList
                )
            }
        ) {
            ReplyAppContent(
                replyUiState = replyUiState,
                onTabPressed = onTabPressed,
                onEmailCardPressed = onEmailCardPressed,
                navigationItemContentList = navigationItemContentList,
                modifier = modifier

            )
        }
    } else {
        if (replyUiState.isShowingHomepage) {
            ReplyAppContent(
                replyUiState = replyUiState,
                onTabPressed = onTabPressed,
                onEmailCardPressed = onEmailCardPressed,
                navigationItemContentList = navigationItemContentList,
                modifier = modifier
            )
        } else {
            ReplyDetailsScreen(
                replyUiState = replyUiState,
                onBackPressed = onDetailScreenBackPressed,
                modifier = modifier
            )
        }
    }
}
...
  1. ReplyHomeScreen 可组合项添加实验性注解。您需要执行此操作,因为 PermanentNavigationDrawer API 仍处于实验阶段。

ReplyHomeScreen.kt

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReplyHomeScreen(
    navigationType: ReplyNavigationType,
    replyUiState: ReplyUiState,
    onTabPressed: (MailboxType) -> Unit = {},
    onEmailCardPressed: (Email) -> Unit = {},
    onDetailScreenBackPressed: () -> Unit = {},
    modifier: Modifier = Modifier
) {
...
  1. Tablet 模式下运行应用。您会看到以下屏幕:

Reply 应用在平板电脑模式下显示,其中抽屉式导航栏位于屏幕左侧,电子邮件列表位于右侧。

实现侧边导航栏

与抽屉式导航栏实现类似,您需要使用 navigationType 参数在导航元素之间切换。

我们先来为中等屏幕添加一个侧边导航栏。

  1. 首先,通过将 navigationType 添加为参数,准备 ReplyAppContent 可组合项。

ReplyHomeScreen.kt

...
@Composable
private fun ReplyAppContent(
    navigationType: ReplyNavigationType,
    replyUiState: ReplyUiState,
    onTabPressed: ((MailboxType) -> Unit) = {},
    onEmailCardPressed: (Email) -> Unit = {},
    navigationItemContentList: List<NavigationItemContent>,
    modifier: Modifier = Modifier
) {
...
  1. navigationType 值传入两个 ReplyAppContent 可组合项。

ReplyHomeScreen.kt

...
            ReplyAppContent(
                navigationType = navigationType,
                replyUiState = replyUiState,
                onTabPressed = onTabPressed,
                onEmailCardPressed = onEmailCardPressed,
                navigationItemContentList = navigationItemContentList,
                modifier = modifier
            )
        }
    } else {
        if (replyUiState.isShowingHomepage) {
            ReplyAppContent(
                navigationType = navigationType,
                replyUiState = replyUiState,
                onTabPressed = onTabPressed,
                onEmailCardPressed = onEmailCardPressed,
                navigationItemContentList = navigationItemContentList,
                modifier = modifier
            )
...

然后,我们来添加分支,以允许应用在某些情况下显示侧边导航栏。

  1. ReplyAppContent 可组合项正文的第一行中,将 ReplyNavigationRail 可组合项封装在 AnimatedVisibility 可组合项中,并在 ReplyNavigationType 值为 NavigationRail 时将 visibility 参数设为 true

ReplyHomeScreen.kt

...
@Composable
private fun ReplyAppContent(
    navigationType: ReplyNavigationType,
    replyUiState: ReplyUiState,
    onTabPressed: ((MailboxType) -> Unit) = {},
    onEmailCardPressed: (Email) -> Unit = {},
    navigationItemContentList: List<NavigationItemContent>,
    modifier: Modifier = Modifier
) {
        AnimatedVisibility(visible = navigationType == ReplyNavigationType.NAVIGATION_RAIL) {
            ReplyNavigationRail(
                currentTab = replyUiState.currentMailbox,
                onTabPressed = onTabPressed,
navigationItemContentList = navigationItemContentList
            )
        }
        Column(
            modifier = Modifier
                .fillMaxSize()            .background(MaterialTheme.colorScheme.inverseOnSurface)
        ) {
            ReplyListOnlyContent(
                replyUiState = replyUiState,
                onEmailCardPressed = onEmailCardPressed,
                modifier = Modifier.weight(1f)
            )
            ReplyBottomNavigationBar(
                currentTab = replyUiState.currentMailbox,
                onTabPressed = onTabPressed,
                navigationItemContentList = navigationItemContentList

            )
        }

}
...
  1. 若要正确对齐可组合项,请将 ReplyAppContent 正文中的 AnimatedVisibility 可组合项和 Column 可组合项封装在 Row 可组合项中。

ReplyHomeScreen.kt

...
@Composable
private fun ReplyAppContent(
    navigationType: ReplyNavigationType,
    replyUiState: ReplyUiState,
    onTabPressed: ((MailboxType) -> Unit) = {},
    onEmailCardPressed: (Email) -> Unit = {},
    navigationItemContentList: List<NavigationItemContent>,
    modifier: Modifier = Modifier
) {
    Row(modifier = modifier.fillMaxSize()) {
        AnimatedVisibility(visible = navigationType == ReplyNavigationType.NAVIGATION_RAIL) {
            ReplyNavigationRail(
                currentTab = replyUiState.currentMailbox,
                onTabPressed = onTabPressed,
                navigationItemContentList = navigationItemContentList
            )
        }
        Column(
            modifier = Modifier
                .fillMaxSize()            .background(MaterialTheme.colorScheme.inverseOnSurface)
        ) {
            ReplyListOnlyContent(
                replyUiState = replyUiState,
                onEmailCardPressed = onEmailCardPressed,
                modifier = Modifier.weight(1f)
            )
            ReplyBottomNavigationBar(
                currentTab = replyUiState.currentMailbox,
                onTabPressed = onTabPressed,
                navigationItemContentList = navigationItemContentList

            )
        }
    }
}
...

最后,我们来确保在某些情况下显示底部导航栏。

  1. ReplyListOnlyContent 可组合项之后,使用 AnimatedVisibility 可组合项封装 ReplyBottomNavigationBar 可组合项。
  2. ReplyNavigationType 的值为 BOTTOM_NAVIGATION 时,设置 visible 参数。

ReplyHomeScreen.kt

...
            ReplyListOnlyContent(
                replyUiState = replyUiState,
                onEmailCardPressed = onEmailCardPressed,
                modifier = Modifier.weight(1f)
            )
            AnimatedVisibility(visible = navigationType == ReplyNavigationType.BOTTOM_NAVIGATION) {
                ReplyBottomNavigationBar(
                    currentTab = replyUiState.currentMailbox,
                    onTabPressed = onTabPressed,
                    navigationItemContentList = navigationItemContentList
                )
            }
...
  1. Unfolded foldable 模式下运行应用。您应该会看到以下屏幕:

Reply 应用在可折叠设备上显示,其中侧边导航栏位于屏幕左侧,电子邮件列表位于右侧。

8. 获取解决方案代码

如需下载完成后的 Codelab 代码,您可以使用以下 Git 命令:

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-reply-app.git

cd basic-android-kotlin-compose-training-reply-app
git checkout nav-update

或者,您也可以下载 ZIP 文件形式的代码库,将其解压缩并在 Android Studio 中打开。

如果您想查看解决方案代码,请前往 GitHub 查看

9. 总结

恭喜!通过实现自适应导航布局,您距离让 Reply 应用能够适应所有屏幕尺寸又近了一步。您提升了用户在多种 Android 设备类型上的体验。在下一个 Codelab 中,您将通过实现自适应内容布局、测试和预览,进一步提高您打造自适应应用的技能。

别忘了使用 #AndroidBasics 标签在社交媒体上分享您的作品!

了解更多内容