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

1. 简介

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

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

前提条件

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

学习内容

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

构建内容

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

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

56cfa13ef31d0b59.png

​​

所需条件

  • 一台连接到互联网并安装了网络浏览器和 Android Studio 的计算机
  • 能够访问 GitHub

2. 应用概览

Reply 应用简介

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

a1af0f9193718abf.png

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

下载起始代码

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

3. 起始代码演示

Reply 应用中的重要目录

Reply 应用文件目录显示了两个已展开的子目录:

Reply 应用项目的数据和界面层分到了不同的目录中。ReplyViewModel、ReplyUiState 和其他可组合项位于 ui 目录中。用于定义数据层的 data 和 enum 类以及数据提供程序类均位于 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 可组合项作为主要可组合项,viewModel 和 uiState 会在其中声明。各种 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.kt 的 ReplyHomeScreen 可组合项中,针对 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 分支。将 replyUIState、onDetailScreenBackPressed 和 modifier 添加为 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。

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

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

“设备管理器”工具栏中显示了两个菜单选项,包括“Create Virtual Device”。

  1. 依次选择 Phone 类别和 Resizable (Experimental) 设备。
  2. 点击下一步。

“Device Manager”窗口显示了一条选择设备定义的提示。系统显示了一系列选项,这些选项上面有一个搜索字段。类别

  1. 选择 API 级别 34 或更高级别。
  2. 点击下一步。

“Virtual Device Configuration”窗口显示了一条选择系统映像的提示。选择了 API 级别 34。

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

系统显示了 Android 虚拟设备 (AVD) 中的“Virtual Configration”屏幕。配置屏幕中包含一个用于输入 AVD 名称的文本字段。名称字段下方是一系列设备选项,包括设备定义(“Resizable [Experimental]”)、系统映像(“Tiramisu”)和屏幕方向(“Portrait”屏幕方向默认处于选中状态)。按钮

在大屏模拟器上运行应用

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

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

bfacf9c20a30b06b.png

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

bb0fa5e954f6ca4b.png

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

专为大屏幕而设计

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

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

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

f50e77a4ffd923a.png

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

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.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. 将 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. 实现自适应导航布局

实现自适应界面导航

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

f39984211e4dd665.png

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

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

侧边导航栏是 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 的 final 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. 首先,通过将 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 值为 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. 若要正确对齐可组合项,请将 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) {
        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. 当 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. 在 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 标签在社交媒体上分享您的作品!

了解详情