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

1. 简介

在 Android 平台上开发应用程序的一大优势在于,它能够接触到各种不同形态因子的用户,例如可穿戴设备、折叠屏设备、平板电脑、桌面电脑,甚至电视。当用户使用应用程序时,他们可能希望在更大的屏幕设备上使用同一应用程序,以利用更大的屏幕空间。越来越多的 Android 用户在各种不同屏幕尺寸的设备上使用他们的应用程序,并期望在所有设备上都能获得高质量的用户体验。

到目前为止,您已经学习了如何主要为移动设备创建应用程序。在本 Codelab 中,您将学习如何转换您的应用程序,使其能够适应其他屏幕尺寸。您将使用自适应导航布局模式,这些模式对于移动设备和大型屏幕设备(如折叠屏设备、平板电脑和桌面电脑)都美观且易用。

先决条件

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

您将学到什么

  • 如何为简单的应用程序在屏幕之间创建导航,而无需使用 Navigation Graph
  • 如何使用 Jetpack Compose 创建自适应导航布局
  • 如何创建自定义后退处理程序

您将构建什么

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

最终产品将如下面的图像所示

56cfa13ef31d0b59.png

​​

您需要什么

  • 一台连接互联网的电脑、一个网络浏览器和 Android Studio
  • 访问 GitHub

2. 应用概述

Reply 应用简介

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

a1af0f9193718abf.png

它包含 4 个不同的类别,通过不同的选项卡显示,分别是:收件箱、已发送、草稿和垃圾邮件。

下载起始代码

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

3. 起始代码演练

Reply 应用中的重要目录

The Reply App file directory displays two sub-directories that are expanded:

Reply 应用项目的 Data 层和 UI 层被分隔到不同的目录中。ReplyViewModelReplyUiState 和其他 Composable 位于 ui 目录中。定义 Data 层和 Data 提供程序类的 dataenum 类位于 data 目录中。

Reply 应用中的数据初始化

Reply 应用通过 ReplyViewModel 中的 initializeUIState() 方法初始化数据,该方法在 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
        )
    }
...

屏幕级 Composable

与其他应用程序一样,Reply 应用使用 ReplyApp Composable 作为主 Composable,其中声明了 viewModeluiState。各种 viewModel() 函数也作为 lambda 参数传递给 ReplyHomeScreen Composable。

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
    )
}

其他 Composable

  • ReplyHomeScreen.kt:包含主屏幕的屏幕级 Composable,包括导航元素。
  • ReplyHomeContent.kt:包含定义主屏幕更详细 Composable 的 Composable。
  • ReplyDetailsScreen.kt:包含详细信息屏幕的屏幕级 Composable 和较小的 Composable。

在继续下一部分 Codelab 之前,请随意详细了解每个文件,以更深入地了解 Composable。

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

在之前的路径中,您学习了如何使用 NavHostController 类在屏幕之间导航。使用 Compose,您还可以通过利用运行时可变状态,使用简单的条件语句更改屏幕。这在像 Reply 应用这样的小型应用程序中特别有用,在这些应用程序中,您只需要在两个屏幕之间切换。

使用状态更改更改屏幕

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

您将使用条件语句,当用户位于主屏幕时显示主屏幕的内容,当用户不在主屏幕时显示详细信息屏幕。

通过完成以下步骤,修改 Reply 应用以允许在状态更改时更改屏幕

  1. 在 Android Studio 中打开起始代码。
  2. ReplyHomeScreen.kt 中的 ReplyHomeScreen Composable 中,使用 if 语句将 ReplyAppContent Composable 包裹起来,用于 replyUiState 对象的 isShowingHomepage 属性为 true 的情况。

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 Composable 的 else 分支,并在其主体中添加它。将 replyUIStateonDetailScreenBackPressedmodifier 作为参数添加到 ReplyDetailsScreen Composable 中。

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 Composable 会重新组合,并且 if/else 语句在运行时重新评估。这种方法支持在不同屏幕之间导航,而无需使用 NavHostController 类。

8443a3ef1a239f6e.gif

创建自定义后退处理程序

使用 NavHost Composable 在屏幕之间切换的一个优点是,先前屏幕的方向保存在后退栈中。这些保存的屏幕允许系统后退按钮在调用时轻松导航到上一个屏幕。由于 Reply 应用不使用 NavHost,因此您必须手动添加处理后退按钮的代码。接下来您将执行此操作。

完成以下步骤,在 Reply 应用中创建自定义后退处理程序

  1. ReplyDetailsScreen Composable 的第一行,添加一个 BackHandler Composable。
  2. BackHandler Composable 的主体中调用 onBackPressed() 函数。

ReplyDetailsScreen.kt

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

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

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

为了创建可用的应用,开发者需要了解用户在各种形态因子中的体验。因此,您必须从开发过程的开始就测试各种形态因子的应用。

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

Android Studio 通过引入 **可调整大小的模拟器** 来帮助您测试这些场景。

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

  1. 在 Android Studio 中,选择 **工具** > **设备管理器**。

The Tools menu displays a list of options. Device Manager , which appears halfway down the list, is selected.

  1. 在 **设备管理器** 中,单击 **+** 图标以创建虚拟设备。

The device manager toolbar displays two menu options including create virtual device.

  1. 选择 **手机** 类别和 **可调整大小(实验性)** 设备。
  2. 单击 **下一步**。

The Device Manager window displays a prompt to choose a device definition. A list of options displays with a search field above it. The category

  1. 选择 **API 级别 34** 或更高版本。
  2. 单击 **下一步**。

The Virtual Device Configuration window displays a prompt to select a system image. 34 API level is selected.

  1. 为您的新 Android 虚拟设备命名。
  2. 单击 **完成**。

The Virtual Configration screen in Android Virtural Device (AVD) displays. The configuration screen includes a text field to enter the AVD name. Below the name field are a list of device options, including the device definition (Resizable Experimental), the system image (Tiramisu), and the orientation, with Portrait orientation selected by default. Buttons reading

在大屏幕模拟器上运行应用

现在您已经设置了可调整大小的模拟器,让我们看看该应用在大屏幕上的显示效果。

  1. 在可调整大小的模拟器上运行该应用。
  2. 为显示模式选择 **平板电脑**。

bfacf9c20a30b06b.png

  1. 检查平板电脑模式下的横向模式下的应用。

bb0fa5e954f6ca4b.png

请注意,平板电脑屏幕显示是水平拉长的。虽然此方向在功能上有效,但它可能不是最佳利用大屏幕空间的方式。我们接下来解决这个问题。

为大屏幕设计

当您在平板电脑上查看此应用时,您首先想到的可能是它设计不佳且不吸引人。您的想法完全正确:此布局 *并非* 为大屏幕使用而设计的。

在为平板电脑和折叠屏设备等大屏幕设计时,您必须考虑用户人体工程学和用户手指到屏幕的距离。对于移动设备,用户的手指可以轻松触及大部分屏幕;交互式元素(如按钮和导航元素)的位置并不那么重要。但是,对于大屏幕来说,将关键的交互式元素放在屏幕中间会使它们难以触及。

如您在 Reply 应用中看到的,大屏幕设计不仅仅是拉伸或放大 UI 元素以适应屏幕。它是一个机会,可以利用增加的屏幕空间为用户创造不同的体验。例如,您可以在同一屏幕上添加另一个布局,以避免需要导航到另一个屏幕或使多任务处理成为可能。

f50e77a4ffd923a.png

这种设计可以提高用户的工作效率并促进更大的参与度。但在部署此设计之前,您必须首先学习如何为不同的屏幕尺寸创建不同的布局。

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

什么是断点?

您可能想知道如何为同一个应用显示不同的布局。简而言之,就是对不同的状态使用条件语句,就像您在本 Codelab 的开头所做的那样。

要创建自适应应用,需要根据屏幕尺寸更改布局。布局更改的测量点称为断点。Material Design 创建了一个有见地的断点范围,涵盖大多数 Android 屏幕。

A table shows the breakpoint range (in dp) for different device types and setups. 0 to 599 dp is for handsets in portait mode, phones in landscape, compact window size, 4 columns, and 8 minimum margins. 600 to 839 dp is for foldable small tablets in portait or landscape modes, medium window size class, 12 columns, and 12 minimum margins. 840 dp or greater is for a large tablet in portrait or landscape modes, the expanded window size class, 12 columns, and 32 minimum margins. Table notes state that margins and gutters are flexible and don't need to be equal in size and that phones in landscape are considered an exception to still fit within the 0 to 599 dp breakpoint range.

此断点范围表显示,例如,如果您的应用当前正在屏幕尺寸小于 600 dp 的设备上运行,则应显示移动布局。

使用窗口尺寸类

为 Compose 引入的WindowSizeClass API 使 Material Design 断点的实现更加简单。

窗口尺寸类为宽度和高度引入了三类尺寸:紧凑型、中型和扩展型。

The diagram represents the width-based window size classes. The diagram represents the height-based window size classes.

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

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

build.gradle.kts

...
dependencies {
...
    implementation("androidx.compose.material3:material3-window-size-class")
...
  1. 添加依赖项后,点击立即同步以同步 Gradle。

b4c912a45fa8b7f4.png

更新build.gradle.kts 文件后,您现在可以创建一个变量,该变量存储应用窗口在任何给定时间的尺寸。

  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 layoutDirection = LocalLayoutDirection.current
            Surface (
               // ...
            ) {
                val windowSize = calculateWindowSizeClass(this)
                ReplyApp()
...  
  1. 注意calculateWindowSizeClass 语法的红色下划线,它显示了红色灯泡。点击windowSize 变量左侧的红色灯泡,然后选择在“onCreate”上选择“ExperimentalMaterial3WindowSizeClassApi”,以在onCreate() 方法顶部创建注释。

f8029f61dfad0306.png

您可以在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 {
                Surface {
                    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 ReplyAppCompactPreview() {
    ReplyTheme {
        Surface {
            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. 实现自适应导航布局

实现自适应 UI 导航

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

f39984211e4dd665.png

如前所述,此导航元素并非理想之选,因为用户可能会发现难以在较大屏幕上触及这些重要的导航元素。幸运的是,响应式 UI 导航中针对各种窗口尺寸类推荐了不同的导航元素模式。对于 Reply 应用,您可以实现以下元素

A table lists the window size classes and the few items that display. Compact width displays a bottom navigation bar. Medium width displays a navigation rail. Expanded width displays a persistent navigation drawer with a leading edge.

导航栏Material Design提供的另一个导航组件,它允许从应用侧面访问主要目的地的紧凑导航选项。

1c73d20ace67811c.png

类似地,持久/永久导航抽屉Material Design创建,作为另一种为较大屏幕提供符合人体工程学访问方式的选项。

6795fb31e6d4a564.png

实现导航抽屉

要为扩展屏幕创建导航抽屉,您可以使用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
...
    val navigationType: 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
...
@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 的最终 Lambda 参数添加。

ReplyHomeScreen.kt

...
    if (navigationType == ReplyNavigationType.PERMANENT_NAVIGATION_DRAWER
        && replyUiState.isShowingHomepage
    ) {
        PermanentNavigationDrawer(
            drawerContent = {
                PermanentDrawerSheet(Modifier.width(dimensionResource(R.dimen.drawer_width))) {
                    NavigationDrawerContent(
                        selectedDestination = replyUiState.currentMailbox,
                        onTabPressed = onTabPressed,
                        navigationItemContentList = navigationItemContentList,
                        modifier = Modifier
                            .wrapContentWidth()
                            .fillMaxHeight()
                            .background(MaterialTheme.colorScheme.inverseOnSurface)
                            .padding(dimensionResource(R.dimen.drawer_padding_content))
                    )
                }
            }
        ) {
            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 = {
                PermanentDrawerSheet(Modifier.width(dimensionResource(R.dimen.drawer_width))) {
                    NavigationDrawerContent(
                        selectedDestination = replyUiState.currentMailbox,
                        onTabPressed = onTabPressed,
                        navigationItemContentList = navigationItemContentList,
                        modifier = Modifier
                            .wrapContentWidth()
                            .fillMaxHeight()
                            .background(MaterialTheme.colorScheme.inverseOnSurface)
                            .padding(dimensionResource(R.dimen.drawer_padding_content))
                    )
                }
            }
        ) {
            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. 平板电脑模式下运行应用。您应该会看到以下屏幕

2dbbc2f88d08f6a.png

实现导航栏

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

首先,让我们为中等屏幕添加导航栏。

  1. 从准备ReplyAppContent 可组合项开始,方法是添加navigationType 作为参数。

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 值为NAVIGATION_RAIL,则将visible 参数设置为true

ReplyHomeScreen.kt

...
@Composable
private fun ReplyAppContent(
    navigationType: ReplyNavigationType,
    replyUiState: ReplyUiState,
    onTabPressed: ((MailboxType) -> Unit),
    onEmailCardPressed: (Email) -> Unit,
    navigationItemContentList: List<NavigationItemContent>,
    modifier: Modifier = Modifier
) {
    Box(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)
                    .padding(
                        horizontal = dimensionResource(R.dimen.email_list_only_horizontal_padding)
                    )
            )
            ReplyBottomNavigationBar(
                currentTab = replyUiState.currentMailbox,
                onTabPressed = onTabPressed,
                navigationItemContentList = navigationItemContentList,
                  modifier = Modifier
                      .fillMaxWidth()
            )
        }
    }
}     
... 
  1. 要正确对齐可组合项,请将AnimatedVisibility 可组合项和ReplyAppContent 主体中找到的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) {
        AnimatedVisibility(visible = navigationType == ReplyNavigationType.NAVIGATION_RAIL) {
            val navigationRailContentDescription = stringResource(R.string.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)
                    .padding(
                        horizontal = dimensionResource(R.dimen.email_list_only_horizontal_padding)
                )
            )
            ReplyBottomNavigationBar(
                currentTab = replyUiState.currentMailbox,
                onTabPressed = onTabPressed,
                navigationItemContentList = navigationItemContentList,
                modifier = Modifier
                    .fillMaxWidth()
            )
        }
    }
}

... 

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

  1. ReplyListOnlyContent 可组合项之后,将ReplyBottomNavigationBar 可组合项包装在AnimatedVisibility 可组合项中。
  2. ReplyNavigationType 值为BOTTOM_NAVIGATION 时,设置visible 参数。

ReplyHomeScreen.kt

...
ReplyListOnlyContent(
    replyUiState = replyUiState,
    onEmailCardPressed = onEmailCardPressed,
    modifier = Modifier.weight(1f)
        .padding(
            horizontal = dimensionResource(R.dimen.email_list_only_horizontal_padding)
        )

)
AnimatedVisibility(visible = navigationType == ReplyNavigationType.BOTTOM_NAVIGATION) {
    val bottomNavigationContentDescription = stringResource(R.string.navigation_bottom)
    ReplyBottomNavigationBar(
        currentTab = replyUiState.currentMailbox,
        onTabPressed = onTabPressed,
        navigationItemContentList = navigationItemContentList,
        modifier = Modifier
            .fillMaxWidth()
    )
}

... 
  1. 展开式折叠模式下运行应用。您应该会看到以下屏幕

bfacf9c20a30b06b.png

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!

了解更多