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

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 应用项目的数据和界面层分到了不同的目录中。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() 函数也会作为 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 类的情况下,在不同屏幕之间导航。

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 标签在社交媒体上分享您的作品!

了解详情