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

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 个不同的类别,通过不同的选项卡显示,分别是:收件箱 (inbox)、已发送 (sent)、草稿 (draft) 和垃圾邮件 (spam)。

下载初始代码

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

3. 初始代码演练

Reply 应用中的重要目录

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

Reply 应用项目的数据层和 UI 层被分到不同的目录。ReplyViewModelReplyUiState 和其他可组合项位于 ui 目录中。定义数据层和数据提供程序类的 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
        )
    }
...

屏幕级别可组合项

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

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.kt 文件中的 ReplyHomeScreen 可组合项中,使用 if 语句包装 ReplyAppContent 可组合项,条件是 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. 添加一个 else 分支,并在其主体中包含 ReplyDetailsScreen 可组合项。将 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 类。

8443a3ef1a239f6e.gif

创建自定义返回处理程序

使用 NavHost 可组合项切换屏幕的一个优点是,前一个屏幕的方向会保存在返回堆栈中。这些保存的屏幕使得系统返回按钮在调用时可以轻松导航到前一个屏幕。由于 Reply 应用不使用 NavHost,您必须手动添加代码来处理返回按钮。接下来您将进行此操作。

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

  1. ReplyDetailsScreen 可组合项的第一行,添加一个 BackHandler 可组合项。
  2. BackHandler 可组合项的主体中调用 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 中,选择 Tools > Device Manager

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

  1. Device Manager 中,点击 + 图标创建虚拟设备。

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

  1. 选择 Phone 类别和 Resizable (Experimental) 设备。
  2. 点击 Next

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 Level 34 或更高版本。
  2. 点击 Next

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

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

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. 选择 Tablet 作为显示模式。

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 的设备上运行,则应显示移动布局。

使用窗口大小类 (Window Size Classes)

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

Window Size Classes 为宽度和高度引入了三种尺寸类别:Compact(紧凑)、Medium(中等)和 Expanded(扩展)。

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. 添加依赖项后,点击 Sync Now 同步 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 变量左侧的红色灯泡,选择 Opt in for ‘ExperimentalMaterial3WindowSizeClassApi' on ‘onCreate',以在 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. MainActivity.kt 文件的 onCreate() 方法中,将 windowSize 变量传递给 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. 要根据屏幕尺寸更改应用布局,请在 ReplyApp 可组合项中根据 WindowWidthSizeClass 值添加一个 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.

导航轨道 (Navigation rail)Material Design 提供的另一种导航组件,它允许应用从侧边提供紧凑的主目标导航选项。

1c73d20ace67811c.png

类似地,永久性导航抽屉 (permanent navigation drawer)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 可组合项主体中,添加一个 if 语句,条件是 navigationType == ReplyNavigationType.PERMANENT_NAVIGATION_DRAWER && replyUiState.isShowingHomepage

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. Tablet 模式下运行应用。您应该会看到以下屏幕:

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 可组合项,并将 visible 参数设置为 true,如果 ReplyNavigationType 值为 NAVIGATION_RAIL

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 可组合项之后,使用 AnimatedVisibility 可组合项包装 ReplyBottomNavigationBar 可组合项。
  2. 设置 visible 参数,当 ReplyNavigationType 值为 BOTTOM_NAVIGATION 时。

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. Unfolded foldable 模式下运行应用。您应该会看到以下屏幕:

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 分享您的作品!

了解更多