Compose 中的基本布局

1. 简介

作为一款 UI 工具包,Compose 可以轻松实现您的应用设计。您只需描述希望 UI 的外观,Compose 就会负责将其绘制到屏幕上。本 Codelab 将指导您如何编写 Compose UI。本 Codelab 假设您已理解 基础知识 Codelab 中讲解的概念,因此请务必先完成该 Codelab。在基础知识 Codelab 中,您学习了如何使用 SurfacesRowsColumns 实现简单的布局。您还使用 paddingfillMaxWidthsize 等修饰符增强了这些布局。

在本 Codelab 中,您将实现更具真实性和复杂性的布局,在此过程中了解各种开箱即用的可组合函数修饰符。完成本 Codelab 后,您应该能够将基本应用的设计转化为可运行的代码。

本 Codelab 不会为应用添加任何实际行为。如果您想了解状态和交互,请改为完成 Compose 中的状态 Codelab

在您学习本 Codelab 期间,如需更多支持,请查看以下代码讲解视频:

学习内容

在本 Codelab 中,您将学习:

  • 修饰符如何帮助您增强可组合函数。
  • Column 和 LazyRow 等标准布局组件如何放置子可组合函数。
  • 对齐方式和排列方式如何改变子可组合函数在其父项中的位置。
  • Scaffold 和底部导航等 Material 可组合函数如何帮助您创建综合布局。
  • 如何使用槽位 API 构建灵活的可组合函数。
  • 如何针对不同的屏幕配置构建布局。

准备工作

将要构建的应用

在本 Codelab 中,您将根据设计师提供的模拟图实现一个真实的应用设计。MySoothe 是一款健康应用,列出了各种改善身心健康的方法。它包含一个列出您的最爱收藏的部分,以及一个包含体育锻炼的部分。该应用的外观如下:

af26dcf59c74e995.png

94083c1e68a00295.png

2. 设置

在此步骤中,您将下载包含主题和一些基本设置的代码。

获取代码

本 Codelab 的代码位于 codelab-android-compose GitHub 代码库中。要克隆该代码库,请运行:

$ git clone https://github.com/android/codelab-android-compose

此外,您还可以下载两个 zip 文件:

检查代码

下载的代码包含所有可用 Compose Codelab 的代码。要完成本 Codelab,请在 Android Studio 中打开 BasicLayoutsCodelab 项目。

我们建议您从 main 分支中的代码开始,按照自己的进度逐步完成 Codelab。

3. 从计划开始

我们将从实现应用的纵向设计开始 - 让我们仔细看看:

9825de962ae22604.png

在需要实现设计时,一个好的开始方法是清楚地了解其结构。不要立刻开始编码,而是要分析设计本身。您如何将此 UI 分割成多个可重用的部分

那么让我们来尝试一下我们的设计。在最高的抽象级别,我们可以将此设计分解为两部分:

  • 屏幕内容。
  • 底部导航。

a49160245fc819c3.png

进一步细化,屏幕内容包含三个子部分:

  • 搜索栏。
  • 一个名为“调理身体”的部分。
  • 一个名为“最爱收藏”的部分。

5a60849913489fed.png

在每个部分中,您还可以看到一些被重复使用的较低层级组件:

  • 在水平滚动行中显示的“调理身体”元素。

9f8a4d4b0a940571.png

  • 在水平滚动网格中显示的“最爱收藏”卡片。

a5299e3b1219971.png

现在您已经分析了设计,可以开始为 UI 的每个已识别部分实现可组合函数了。从最低层级的可组合函数开始,然后将它们组合成更复杂的部分。到 Codelab 结束时,您的新应用将看起来像提供的设计。

4. 搜索栏 - 修饰符

第一个要转换为可组合函数的是搜索栏。我们再来看看设计:

907293b875cba19e.png仅凭此屏幕截图,要以像素级完美的方式实现此设计将相当困难。通常,设计师会传达更多关于设计的信息。他们可以授予您访问其设计工具的权限,或分享所谓的“红线图”设计。在本例中,我们的设计师提供了红线图设计,您可以使用它来读取任何尺寸值。设计图带有 8dp 网格叠加层,因此您可以轻松查看元素之间和周围的空间大小。此外,还明确添加了一些间距以澄清某些尺寸。

73b1b3df76ae5f07.png

您可以看到搜索栏的高度应为 56 密度无关像素。它还应该填充其父项的全部宽度。

要实现搜索栏,请使用名为 文本字段 的 Material 组件。Compose Material 库包含一个名为 TextField 的可组合函数,它是此 Material 组件的实现。

从基本的 TextField 实现开始。在您的代码库中,打开 MainActivity.kt 并搜索 SearchBar 可组合函数。

在名为 SearchBar 的可组合函数内部,编写基本的 TextField 实现:

import androidx.compose.material3.TextField

@Composable
fun SearchBar(
   modifier: Modifier = Modifier
) {
   TextField(
       value = "",
       onValueChange = {},
       modifier = modifier
   )
}

需要注意的几点:

  • 您对文本字段的值进行了硬编码,并且 onValueChange 回调没有任何作用。由于这是一个侧重于布局的 Codelab,您可以忽略任何与状态相关的内容。
  • The SearchBar composable function accepts a modifier parameter and passes this on to the TextField. This is a best practice as per Compose guidelines. This allows the method's caller to modify the composable's look & feel, which makes it more flexible and reusable. You'll continue this best practice for all composables in this codelab. -> SearchBar 可组合函数接受一个 modifier 参数,并将其传递给 TextField。根据 Compose 指南,这是一种最佳实践。这样做可以让方法的调用方修改可组合函数的外观和感受,从而使其更灵活、更可重用。在本 Codelab 的所有可组合函数中,您都将继续遵循此最佳实践。

让我们看看这个可组合函数的预览。请记住,您可以使用 Android Studio 中的预览功能来快速迭代您的单个可组合函数。MainActivity.kt 包含您将在本 Codelab 中构建的所有可组合函数的预览。在本例中,SearchBarPreview 方法会渲染我们的 SearchBar 可组合函数,并添加了一些背景和内边距以提供更多上下文。使用您刚刚添加的实现,它应该看起来像这样:

f9a7c6602c84f652.png

还缺少一些东西。首先,让我们使用修饰符来固定可组合函数的大小。

编写可组合函数时,您可以使用修饰符来:

  • 更改可组合函数的大小、布局、行为和外观。
  • 添加信息,如无障碍标签。
  • 处理用户输入。
  • 添加高级交互,如使元素可点击、可滚动、可拖动或可缩放。

您调用的每个可组合函数都有一个 modifier 参数,您可以设置该参数来调整可组合函数的外观、感受和行为。设置修饰符时,您可以链式调用多个修饰符方法来创建更复杂的调整。

在本例中,搜索栏的高度应至少为 56dp,并填充其父项的宽度。要找到适合此需求的修饰符,您可以查阅修饰符列表并查看尺寸部分。对于高度,您可以使用 heightIn 修饰符。这确保可组合函数具有特定的最小高度。但是,当用户放大其系统字体大小时,它可能会变大。对于宽度,您可以使用 fillMaxWidth 修饰符。此修饰符可确保搜索栏占用其父项的所有水平空间。

更新修饰符以匹配以下代码:

import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.material3.TextField

@Composable
fun SearchBar(
   modifier: Modifier = Modifier
) {
   TextField(
       value = "",
       onValueChange = {},
       modifier = modifier
           .fillMaxWidth()
           .heightIn(min = 56.dp)
   )
}

在这种情况下,由于一个修饰符影响宽度,另一个影响高度,因此这些修饰符的顺序无关紧要。

您还需要设置 TextField 的一些参数。尝试通过设置参数值使可组合函数看起来像设计图。这里再次提供设计图作为参考:

9d72db0576c2b916.png

您应该执行以下步骤来更新您的实现:

  • 添加搜索图标。TextField 包含一个参数 leadingIcon,该参数接受另一个可组合函数。在内部,您可以设置一个 Icon,在本例中应该是 Search 图标。请确保使用正确的 Compose Icon 导入。
  • 您可以使用 TextFieldDefaults.colors 来覆盖特定颜色。将文本字段的 focusedContainerColorunfocusedContainerColor 设置为 MaterialTheme 的 surface 颜色。
  • 添加占位文本“Search”(您可以在字符串资源 R.string.placeholder_search 中找到)。

完成后,您的可组合函数应该看起来像这样:

import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.ui.res.stringResource
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search


@Composable
fun SearchBar(
   modifier: Modifier = Modifier
) {
   TextField(
       value = "",
       onValueChange = {},
       leadingIcon = {
           Icon(
               imageVector = Icons.Default.Search,
               contentDescription = null
           )
       },
       colors = TextFieldDefaults.colors(
           unfocusedContainerColor = MaterialTheme.colorScheme.surface,
           focusedContainerColor = MaterialTheme.colorScheme.surface
       ),
       placeholder = {
           Text(stringResource(R.string.placeholder_search))
       },
       modifier = modifier
           .fillMaxWidth()
           .heightIn(min = 56.dp)
   )
}

请注意:

  • 您添加了一个显示搜索图标的 leadingIcon。此图标不需要内容描述,因为文本字段的占位符已经描述了文本字段的含义。请记住,内容描述通常用于无障碍功能目的,并为应用用户提供图像或图标的文本表示。
  • 要调整文本字段的背景颜色,请设置 colors 属性。可组合函数不为每种颜色设置单独的参数,而是包含一个组合参数。在此处,您传入 TextFieldDefaults 数据类的一个副本,仅更新不同的颜色。在本例中,仅更新 unfocusedContainerColorfocusedContainerColor 颜色。

在此步骤中,您了解了如何使用可组合函数参数和修饰符更改可组合函数的外观和感受。这适用于 Compose 和 Material 库提供的可组合函数,也适用于您自己编写的可组合函数。您应该始终考虑为正在编写的可组合函数提供参数以进行自定义。您还应该添加一个 modifier 属性,以便可以从外部调整可组合函数的外观和感受。

5. 调理身体 - 对齐方式

您将要实现的下一个可组合函数是“调理身体”元素。让我们来看看它的设计,包括旁边的红线图设计:

52f31d2e422d69e2.png

ea3d96db9dd6c062.png

红线图设计现在还包含基于基线的间距。我们可以从中获得以下信息:

  • 图片高度应为 88dp。
  • 文本基线与图片之间的间距应为 24dp。
  • 基线与元素底部之间的间距应为 8dp。
  • 文本应使用 bodyMedium 排版样式。

要实现此可组合函数,您需要一个 Image 和一个 Text 可组合函数。它们需要包含在 Column 中,以便它们垂直排列。

在您的代码中找到 AlignYourBodyElement 可组合函数,并使用此基本实现更新其内容:

import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.ui.res.painterResource

@Composable
fun AlignYourBodyElement(
   modifier: Modifier = Modifier
) {
   Column(
       modifier = modifier
   ) {
       Image(
           painter = painterResource(R.drawable.ab1_inversions),
           contentDescription = null
       )
       Text(text = stringResource(R.string.ab1_inversions))
   }
}

请注意:

  • 您将图片的 contentDescription 设置为 null,因为此图片纯粹是装饰性的。图片下方的文本已充分描述了其含义,因此图片不需要额外的描述。
  • 您正在使用硬编码的图片和文本。在下一步中,您将使用 AlignYourBodyElement 可组合函数中提供的参数来使它们具有动态性。

看看这个可组合函数的预览:

71b61d3ff56b479e.png

还有一些需要改进的地方。最明显的是,图片太大且不是圆形。您可以使用 sizeclip 修饰符以及 contentScale 参数来调整 Image 可组合函数。

The size modifier adapts the composable to fit a certain size, similar to the fillMaxWidth and heightIn modifiers that you saw in the previous step. The clip modifier works differently and adapts the composable's appearance. You can set it to any Shape and it clips the composable's content to that shape. -> size 修饰符会调整可组合函数以适应特定大小,类似于您在上一节中看到的 fillMaxWidthheightIn 修饰符。clip 修饰符的工作方式不同,它会调整可组合函数的外观。您可以将其设置为任何 Shape,它会将可组合函数的内容裁剪成该形状。

import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.draw.clip
@Composable
fun AlignYourBodyElement(
   modifier: Modifier = Modifier
) {
   Column(
       modifier = modifier
   ) {
       Image(
           painter = painterResource(R.drawable.ab1_inversions),
           contentDescription = null,
           modifier = Modifier
               .size(88.dp)
               .clip(CircleShape)
       )
       Text(text = stringResource(R.string.ab1_inversions))
   }
}

当前您在预览中的设计看起来像这样:

61809abae2e61520.png

图片还需要正确缩放。为此,我们可以使用 ImagecontentScale 参数。有几种选项,最常见的包括:

5f17f07fcd0f1dc.png

在本例中,裁剪类型是正确的选择。应用修饰符和参数后,您的代码应如下所示:

import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
@Composable
fun AlignYourBodyElement(
   modifier: Modifier = Modifier
) {
   Column(
       modifier = modifier
   ) {
       Image(
           painter = painterResource(R.drawable.ab1_inversions),
           contentDescription = null,
           contentScale = ContentScale.Crop,
           modifier = Modifier
               .size(88.dp)
               .clip(CircleShape)
       )
       Text( text = stringResource(R.string.ab1_inversions) )
   }
}

您的设计现在应该看起来像这样:

32b7f181d6c486a1.png

下一步,通过设置 Column 的对齐方式来水平对齐文本。

通常,要在父容器内对齐可组合函数,您需要设置该父容器的对齐方式。因此,您不是告诉子项在其父项中定位自己,而是告诉父项如何对其子项进行对齐。

对于 Column,您决定其子项应如何水平对齐。选项包括:

  • 起始
  • 水平居中
  • 结束

对于 Row,您设置垂直对齐方式。选项与 Column 的选项类似:

  • 顶部
  • 垂直居中
  • 底部

对于 Box,您可以结合水平和垂直对齐方式。选项包括:

  • 顶部起始
  • 顶部居中
  • 顶部结束
  • 垂直居中起始
  • 居中
  • 垂直居中结束
  • 底部起始
  • 底部居中
  • 底部结束

容器的所有子项都将遵循相同的对齐模式。您可以通过向单个子项添加 align 修饰符来覆盖其行为。

对于此设计,文本应该水平居中。为此,将 ColumnhorizontalAlignment 设置为水平居中:

import androidx.compose.ui.Alignment
@Composable
fun AlignYourBodyElement(
   modifier: Modifier = Modifier
) {
   Column(
       horizontalAlignment = Alignment.CenterHorizontally,
       modifier = modifier
   ) {
       Image(
           //..
       )
       Text(
           //..
       )
   }
}

实现这些部分后,只需进行一些微小的更改即可使可组合函数与设计图完全一致。尝试自己实现这些更改,如果遇到困难,可以参考最终代码。考虑以下步骤:

  • 使图片和文本具有动态性。将它们作为参数传递给可组合函数。不要忘记更新相应的预览并传入一些硬编码的数据。
  • 更新文本以使用 bodyMedium 排版样式。
  • 根据图示更新文本元素的基线间距。

9b0505a98255508b.png

完成这些步骤后,您的代码应该看起来像这样:

import androidx.compose.foundation.layout.paddingFromBaseline
import androidx.compose.ui.Alignment
import androidx.compose.ui.layout.ContentScale

@Composable
fun AlignYourBodyElement(
   @DrawableRes drawable: Int,
   @StringRes text: Int,
   modifier: Modifier = Modifier
) {
   Column(
       modifier = modifier,
       horizontalAlignment = Alignment.CenterHorizontally
   ) {
       Image(
           painter = painterResource(drawable),
           contentDescription = null,
           contentScale = ContentScale.Crop,
           modifier = Modifier
               .size(88.dp)
               .clip(CircleShape)
       )
       Text(
           text = stringResource(text),
           modifier = Modifier.paddingFromBaseline(top = 24.dp, bottom = 8.dp),
           style = MaterialTheme.typography.bodyMedium
       )
   }
}


@Preview(showBackground = true, backgroundColor = 0xFFF5F0EE)
@Composable
fun AlignYourBodyElementPreview() {
   MySootheTheme {
       AlignYourBodyElement(
           text = R.string.ab1_inversions,
           drawable = R.drawable.ab1_inversions,
           modifier = Modifier.padding(8.dp)
       )
   }
}

在 Design 标签页中查看 AlignYourBodyElement。

94a07b90fbd0bde.png

6. 最爱收藏卡片 - Material Surface

下一个要实现的可组合函数在某种程度上类似于“调理身体”元素。这是设计图,包括红线图:

52e72a19e67f646d.png

b5a11ff3afd99c09.png

在本例中,提供了可组合函数的完整尺寸。您可以看到文本应使用 titleMedium 排版样式。

此容器使用 surfaceVariant 作为其背景颜色,这与整个屏幕的背景不同。它还具有圆角。我们使用 Material 的 Surface 可组合函数为最爱收藏卡片指定这些属性。

您可以通过设置其参数和修饰符来根据您的需求调整 Surface。在本例中,表面应该具有圆角。您可以使用 shape 参数来实现此目的。与上一步中将 Image 的形状设置为 Shape 不同,您将使用来自 Material 主题的值。

让我们看看它会是什么样子:

import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Surface

@Composable
fun FavoriteCollectionCard(
   modifier: Modifier = Modifier
) {
   Surface(
       shape = MaterialTheme.shapes.medium,
       modifier = modifier
   ) {
       Row {
           Image(
               painter = painterResource(R.drawable.fc2_nature_meditations),
               contentDescription = null
           )
           Text(text = stringResource(R.string.fc2_nature_meditations))
       }
   }
}

让我们看看此实现的预览:

50b88836019b377.png

接下来,应用在上一节中学习的知识。

  • 设置 Row 的宽度,并垂直对齐其子项。
  • 根据图示设置图片的大小并在其容器中裁剪。

85c43a6c27bafb4f.png

在查看解决方案代码之前,尝试自己实现这些更改!

您的代码现在看起来像这样:

import androidx.compose.foundation.layout.width

@Composable
fun FavoriteCollectionCard(
   modifier: Modifier = Modifier
) {
   Surface(
       shape = MaterialTheme.shapes.medium,
       modifier = modifier
   ) {
       Row(
           verticalAlignment = Alignment.CenterVertically,
           modifier = Modifier.width(255.dp)
       ) {
           Image(
               painter = painterResource(R.drawable.fc2_nature_meditations),
               contentDescription = null,
               contentScale = ContentScale.Crop,
               modifier = Modifier.size(80.dp)
           )
           Text(
               text = stringResource(R.string.fc2_nature_meditations)
           )
       }
   }
}

预览现在应该看起来像这样:

26545aa897135433.png

要完成此可组合函数,请执行以下步骤:

  • 使图片和文本具有动态性。将它们作为参数传递给可组合函数。
  • 将颜色更新为 surfaceVariant。
  • 更新文本以使用 titleMedium 排版样式。
  • 更新图片与文本之间的间距。

您的最终结果应该看起来像这样:

@Composable
fun FavoriteCollectionCard(
   @DrawableRes drawable: Int,
   @StringRes text: Int,
   modifier: Modifier = Modifier
) {
   Surface(
       shape = MaterialTheme.shapes.medium,
       color = MaterialTheme.colorScheme.surfaceVariant,
       modifier = modifier
   ) {
       Row(
           verticalAlignment = Alignment.CenterVertically,
           modifier = Modifier.width(255.dp)
       ) {
           Image(
               painter = painterResource(drawable),
               contentDescription = null,
               contentScale = ContentScale.Crop,
               modifier = Modifier.size(80.dp)
           )
           Text(
               text = stringResource(text),
               style = MaterialTheme.typography.titleMedium,
               modifier = Modifier.padding(horizontal = 16.dp)
           )
       }
   }
}


//..


@Preview(showBackground = true, backgroundColor = 0xFFF5F0EE)
@Composable
fun FavoriteCollectionCardPreview() {
   MySootheTheme {
       FavoriteCollectionCard(
           text = R.string.fc2_nature_meditations,
           drawable = R.drawable.fc2_nature_meditations,
           modifier = Modifier.padding(8.dp)
       )
   }
}

查看 FavoriteCollectionCardPreview 的预览。

70fe9b9a5531b55.png

7. 调理身体行 - 排列方式

现在您已经创建了屏幕上显示的基本可组合函数,可以开始创建屏幕的不同部分了。

从“调理身体”可滚动行开始。

378dc391bf6f10f.gif

这是此组件的红线图设计:

190d80ae866ad58d.png

请记住,网格的一个块代表 8dp。因此,在此设计中,行中第一个项目之前和最后一个项目之后有 16dp 的空间。每个项目之间有 8dp 的间距。

在 Compose 中,您可以使用 LazyRow 可组合函数实现这样的可滚动行。列表文档包含更多关于 LazyRowLazyColumn 等 Lazy 列表的信息。对于本 Codelab,您只需知道 LazyRow 只渲染屏幕上显示的元素,而不是同时渲染所有元素,这有助于保持应用的性能。

从此 LazyRow 的基本实现开始:

import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items

@Composable
fun AlignYourBodyRow(
   modifier: Modifier = Modifier
) {
   LazyRow(
       modifier = modifier
   ) {
       items(alignYourBodyData) { item ->
           AlignYourBodyElement(item.drawable, item.text)
       }
   }
}

如您所见,LazyRow 的子项不是可组合函数。相反,您使用 Lazy 列表 DSL,它提供了 itemitems 等方法,这些方法会以列表项的形式发出可组合函数。对于提供的 alignYourBodyData 中的每个项目,您都会发出一个您之前实现的 AlignYourBodyElement 可组合函数。

注意这是如何显示的:

7fc50fa534a91430.png

我们在红线图设计中看到的间距仍然缺失。要实现这些间距,您必须了解排列方式

在上一步中,您学习了对齐方式,它用于在容器的交叉轴上对齐其子项。对于 Column,交叉轴是水平轴;对于 Row,交叉轴是垂直轴。

但是,我们也可以决定如何将子可组合函数放置在容器的主轴上(Row 的主轴是水平轴,Column 的主轴是垂直轴)。

对于 Row,您可以选择以下排列方式:

c1e6c40e30136af2.gif

对于 Column

df69881d07b064d0.gif

除了这些排列方式,您还可以使用 Arrangement.spacedBy() 方法在每个子可组合函数之间添加固定间距。

在示例中,您需要使用 spacedBy 方法,因为您希望在 LazyRow 中的每个项目之间放置 8dp 的间距。

import androidx.compose.foundation.layout.Arrangement

@Composable
fun AlignYourBodyRow(
   modifier: Modifier = Modifier
) {
   LazyRow(
       horizontalArrangement = Arrangement.spacedBy(8.dp),
       modifier = modifier
   ) {
       items(alignYourBodyData) { item ->
           AlignYourBodyElement(item.drawable, item.text)
       }
   }
}

现在设计看起来像这样:

432399130e1b79c8.png

您还需要在 LazyRow 的两侧添加一些内边距。在这种情况下,添加一个简单的 padding 修饰符无法达到目的。尝试向 LazyRow 添加 padding,并使用交互式预览查看其行为。

1210a4da54a9d1bd.gif

如您所见,滚动时,第一个和最后一个可见项目在屏幕两侧被截断。

为了保持相同的内边距,同时仍能在父列表的范围内滚动内容而不裁剪,所有列表都为 LazyRow 提供了一个名为 contentPadding 的参数,并将其设置为 16.dp

import androidx.compose.foundation.layout.PaddingValues

@Composable
fun AlignYourBodyRow(
   modifier: Modifier = Modifier
) {
   LazyRow(
       horizontalArrangement = Arrangement.spacedBy(8.dp),
       contentPadding = PaddingValues(horizontal = 16.dp),
       modifier = modifier
   ) {
       items(alignYourBodyData) { item ->
           AlignYourBodyElement(item.drawable, item.text)
       }
   }
}

8. 最爱收藏网格 - Lazy 网格

要实现的下一个部分是屏幕的“最爱收藏”部分。此可组合函数需要一个网格,而不是单个行:

ee7c454636bd5939.gif

您可以像上一节一样实现此部分,创建一个 LazyRow,并让每个项目容纳一个包含两个 FavoriteCollectionCard 实例的 Column。但是,在此步骤中,您将使用 LazyHorizontalGrid,它提供了更好的项目到网格元素的映射。

从具有两个固定行的网格的简单实现开始:

import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
import androidx.compose.foundation.lazy.grid.items

@Composable
fun FavoriteCollectionsGrid(
   modifier: Modifier = Modifier
) {
   LazyHorizontalGrid(
       rows = GridCells.Fixed(2),
       modifier = modifier
   ) {
       items(favoriteCollectionsData) { item ->
           FavoriteCollectionCard(item.drawable, item.text)
       }
   }
}

如您所见,您只需将上一节中的 LazyRow 替换为 LazyHorizontalGrid。但是,这还不会立即给您带来正确的结果:

4da2ecb238171bed.png

网格占用了与其父项一样多的空间,这意味着最爱收藏卡片在垂直方向上被过度拉伸了。

调整可组合函数,使其:

  • 网格具有 16.dp 的水平 contentPadding。
  • 水平和垂直排列间隔 16.dp。
  • 网格的高度为 168.dp。
  • FavoriteCollectionCard 的修饰符指定高度为 80.dp。

最终代码应如下所示:

@Composable
fun FavoriteCollectionsGrid(
   modifier: Modifier = Modifier
) {
   LazyHorizontalGrid(
       rows = GridCells.Fixed(2),
       contentPadding = PaddingValues(horizontal = 16.dp),
       horizontalArrangement = Arrangement.spacedBy(16.dp),
       verticalArrangement = Arrangement.spacedBy(16.dp),
       modifier = modifier.height(168.dp)
   ) {
       items(favoriteCollectionsData) { item ->
           FavoriteCollectionCard(item.drawable, item.text, Modifier.height(80.dp))
       }
   }
}

预览应如下所示:

fbe51e89e1e74b8d.png

9. 主屏幕部分 - 槽位 API

在 MySoothe 主屏幕中,有多个遵循相同模式的部分。每个部分都有一个标题,内容根据部分而异。这是我们要实现的设计:

8d70500bc8e296cb.png

如您所见,每个部分都有一个标题和一个槽位。标题包含一些与之关联的间距和样式信息。根据部分的不同,槽位可以动态填充不同的内容。

为了实现这个灵活的部分容器,您使用所谓的*槽位 API*。在实现之前,请阅读文档页面中关于基于槽位的布局的部分。这将帮助您理解什么是基于槽位的布局以及如何使用槽位 API 构建此类布局。

调整 HomeSection 可组合函数以接收标题和槽位内容。您还应该调整相关的预览,使其使用“调理身体”标题和内容调用此 HomeSection

@Composable
fun HomeSection(
   @StringRes title: Int,
   modifier: Modifier = Modifier,
   content: @Composable () -> Unit
) {
   Column(modifier) {
       Text(stringResource(title))
       content()
   }
}

@Preview(showBackground = true, backgroundColor = 0xFFF5F0EE)
@Composable
fun HomeSectionPreview() {
   MySootheTheme {
       HomeSection(R.string.align_your_body) {
           AlignYourBodyRow()
       }
   }
}

您可以使用 content 参数作为可组合函数的槽位。这样,当您使用 HomeSection 可组合函数时,您可以使用尾随 lambda 来填充内容槽位。当一个可组合函数提供多个槽位来填充时,您可以给它们赋予有意义的名称,以代表它们在更大的可组合容器中的功能。例如,Material 的 TopAppBar 提供了用于 titlenavigationIconactions 的槽位。

让我们看看使用此实现后该部分的外观:

37f9e54a3d56ba46.png

Text 可组合函数需要更多信息才能使其与设计图对齐。

87c1159591a61aa.png

更新它,使其:

  • 使用 titleMedium 排版。
  • 文本基线与顶部之间的间距为 40dp。
  • 基线与元素底部之间的间距为 16dp。
  • 水平内边距为 16dp。

您的最终解决方案应如下所示:

@Composable
fun HomeSection(
   @StringRes title: Int,
   modifier: Modifier = Modifier,
   content: @Composable () -> Unit
) {
   Column(modifier) {
       Text(
           text = stringResource(title),
           style = MaterialTheme.typography.titleMedium,
           modifier = Modifier
               .paddingFromBaseline(top = 40.dp, bottom = 16.dp)
               .padding(horizontal = 16.dp)
       )
       content()
   }
}

10. 主屏幕 - 滚动

现在您已经创建了所有单独的构建块,可以将它们组合成一个全屏实现。

这是您要实现的设计:

3c2a284aa77735ca.png

我们只是将搜索栏和两个部分垂直放置。您需要添加一些间距,使所有内容符合设计。我们之前没有使用过的一个可组合函数是 Spacer,它可以帮助我们在 Column 内部添加额外空间。如果您改为设置 Column 的 padding,将会出现与我们在最爱收藏网格中看到的相同的截断行为。

@Composable
fun HomeScreen(modifier: Modifier = Modifier) {
   Column(modifier) {
       Spacer(Modifier.height(16.dp))
       SearchBar(Modifier.padding(horizontal = 16.dp))
       HomeSection(title = R.string.align_your_body) {
           AlignYourBodyRow()
       }
       HomeSection(title = R.string.favorite_collections) {
           FavoriteCollectionsGrid()
       }
       Spacer(Modifier.height(16.dp))
   }
}

尽管设计在大多数设备尺寸上都能很好地显示,但在设备高度不够时(例如在横屏模式下),它需要能够垂直滚动。这就要求您添加滚动行为。

如前所述,LazyRowLazyHorizontalGrid 等 Lazy 布局会自动添加滚动行为。但是,您并不总是需要 Lazy 布局。通常,当列表中有许多元素或需要加载大型数据集时,您才使用 Lazy 布局,因为一次性发出所有项目会带来性能开销并降低应用速度。当列表中的元素数量有限时,您可以选择使用简单的 ColumnRow手动添加滚动行为。为此,您使用 verticalScrollhorizontalScroll 修饰符。这些修饰符需要一个 ScrollState,它包含当前的滚动状态,用于从外部修改滚动状态。在本例中,您不需要修改滚动状态,因此只需使用 rememberScrollState 创建一个持久的 ScrollState 实例。

您的最终结果应如下所示:

import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll

@Composable
fun HomeScreen(modifier: Modifier = Modifier) {
   Column(
       modifier
           .verticalScroll(rememberScrollState())
   ) {
       Spacer(Modifier.height(16.dp))
       SearchBar(Modifier.padding(horizontal = 16.dp))
       HomeSection(title = R.string.align_your_body) {
           AlignYourBodyRow()
       }
       HomeSection(title = R.string.favorite_collections) {
           FavoriteCollectionsGrid()
       }
       Spacer(Modifier.height(16.dp))
   }
}

要验证可组合函数的滚动行为,请限制预览的高度并在交互式预览中运行。

@Preview(showBackground = true, backgroundColor = 0xFFF5F0EE, heightDp = 180)
@Composable
fun ScreenContentPreview() {
   MySootheTheme { HomeScreen() }
}

11. 底部导航 - Material

现在您已经实现了屏幕内容,接下来可以添加窗口装饰了。对于 MySoothe,有一个导航栏,用户可以在不同屏幕之间切换。

首先,实现导航栏可组合函数,然后将其包含在您的应用中。

让我们来看看设计:

7fe4985abb54445a.png

幸运的是,您不必自己从头开始实现整个可组合函数。您可以使用 Compose Material 库中的 NavigationBar 可组合函数。在 NavigationBar 可组合函数内部,您可以添加一个或多个 NavigationBarItem 元素,这些元素将由 Material 库自动设置样式。

从此底部导航的基本实现开始:

import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Spa

@Composable
private fun SootheBottomNavigation(modifier: Modifier = Modifier) {
   NavigationBar(
       modifier = modifier
   ) {
       NavigationBarItem(
           icon = {
               Icon(
                   imageVector = Icons.Default.Spa,
                   contentDescription = null
               )
           },
           label = {
               Text(
                   text = stringResource(R.string.bottom_navigation_home)
               )
           },
           selected = true,
           onClick = {}
       )
       NavigationBarItem(
           icon = {
               Icon(
                   imageVector = Icons.Default.AccountCircle,
                   contentDescription = null
               )
           },
           label = {
               Text(
                   text = stringResource(R.string.bottom_navigation_profile)
               )
           },
           selected = false,
           onClick = {}
       )
   }
}

这是基本实现的外观 - 内容颜色与导航栏颜色之间没有太多对比度。

3a5988f4e135ba58.png

您应该进行一些样式调整。首先,您可以通过设置底部导航的 containerColor 参数来更新其背景颜色。您可以使用 Material 主题中的 surfaceVariant 颜色。您的最终解决方案应如下所示:

@Composable
private fun SootheBottomNavigation(modifier: Modifier = Modifier) {
   NavigationBar(
       containerColor = MaterialTheme.colorScheme.surfaceVariant,
       modifier = modifier
   ) {
       NavigationBarItem(
           icon = {
               Icon(
                   imageVector = Icons.Default.Spa,
                   contentDescription = null
               )
           },
           label = {
               Text(stringResource(R.string.bottom_navigation_home))
           },
           selected = true,
           onClick = {}
       )
       NavigationBarItem(
           icon = {
               Icon(
                   imageVector = Icons.Default.AccountCircle,
                   contentDescription = null
               )
           },
           label = {
               Text(stringResource(R.string.bottom_navigation_profile))
           },
           selected = false,
           onClick = {}
       )
   }
}

现在导航栏应该看起来像这样,请注意它提供了更多对比度。

c78fee1cb0263bf3.png

12. MySoothe 应用 - Scaffold

在此步骤中,创建全屏实现,包括底部导航。使用 Material 的 Scaffold 可组合函数。Scaffold 为实现 Material Design 的应用提供了一个顶层可配置的可组合函数。它包含各种 Material 概念的槽位,其中之一是底部栏。在此底部栏中,您可以放置您在上一步中创建的底部导航可组合函数。

实现 MySootheAppPortrait() 可组合函数。这是您的应用的顶层可组合函数,因此您应该:

  • 应用 MySootheTheme Material 主题。
  • 添加 Scaffold
  • 将底部栏设置为您的 SootheBottomNavigation 可组合函数。
  • 将内容设置为您的 HomeScreen 可组合函数。

您的最终结果应为:

import androidx.compose.material3.Scaffold

@Composable
fun MySootheAppPortrait() {
   MySootheTheme {
       Scaffold(
           bottomBar = { SootheBottomNavigation() }
       ) { padding ->
           HomeScreen(Modifier.padding(padding))
       }
   }
}

您的实现现在已完成!如果您想检查您的版本是否以像素级完美的方式实现,可以将此图片与您自己的预览实现进行比较。

ef4f392d3ad1ecf7.png

13. 导航栏 - Material

创建应用布局时,您还需要注意它在多种配置下的外观,包括手机的横屏模式。这是应用在横屏模式下的设计,注意底部导航如何变成了屏幕内容左侧的导航栏。

14ea5bb18785e4a0.png

要实现此功能,您将使用 NavigationRail 可组合函数,它是 Compose Material 库的一部分,其实现与用于创建底部导航栏的 NavigationBar 类似。在 NavigationRail 可组合函数内部,您将为 Home 和 Profile 添加 NavigationRailItem 元素。

8b6b1e17e374ae56.png

让我们从 Navigation Rail 的基本实现开始。

import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem

@Composable
private fun SootheNavigationRail(modifier: Modifier = Modifier) {
   NavigationRail(
   ) {
       Column(
       ) {
           NavigationRailItem(
               icon = {
                   Icon(
                       imageVector = Icons.Default.Spa,
                       contentDescription = null
                   )
               },
               label = {
                   Text(stringResource(R.string.bottom_navigation_home))
               },
               selected = true,
               onClick = {}
           )

           NavigationRailItem(
               icon = {
                   Icon(
                       imageVector = Icons.Default.AccountCircle,
                       contentDescription = null
                   )
               },
               label = {
                   Text(stringResource(R.string.bottom_navigation_profile))
               },
               selected = false,
               onClick = {}
           )
       }
   }
}

afaa7588f4081ffb.png

您应该进行一些样式调整。

  • 在导航栏的起始和末尾添加 8.dp 的内边距。
  • 通过使用 Material 主题的背景颜色设置其 containerColor 参数来更新导航栏的背景颜色。通过设置背景颜色,图标和文本的颜色会自动适应主题的 onBackground 颜色。
  • 列应填充最大高度。
  • 将列的垂直排列设置为居中。
  • 将列的水平对齐设置为水平居中。
  • 在两个图标之间添加 8.dp 的内边距。

您的最终解决方案应如下所示:

import androidx.compose.foundation.layout.fillMaxHeight

@Composable
private fun SootheNavigationRail(modifier: Modifier = Modifier) {
   NavigationRail(
       modifier = modifier.padding(start = 8.dp, end = 8.dp),
       containerColor = MaterialTheme.colorScheme.background,
   ) {
       Column(
           modifier = modifier.fillMaxHeight(),
           verticalArrangement = Arrangement.Center,
           horizontalAlignment = Alignment.CenterHorizontally
       ) {
           NavigationRailItem(
               icon = {
                   Icon(
                       imageVector = Icons.Default.Spa,
                       contentDescription = null
                   )
               },
               label = {
                   Text(stringResource(R.string.bottom_navigation_home))
               },
               selected = true,
               onClick = {}
           )
           Spacer(modifier = Modifier.height(8.dp))
           NavigationRailItem(
               icon = {
                   Icon(
                       imageVector = Icons.Default.AccountCircle,
                       contentDescription = null
                   )
               },
               label = {
                   Text(stringResource(R.string.bottom_navigation_profile))
               },
               selected = false,
               onClick = {}
           )
       }
   }
}

efbaa88c691c106e.png

现在,我们将 Navigation Rail 添加到横屏布局中。

93883b6cebbbe6a5.png

对于应用的纵向版本,您使用了 Scaffold。但是,对于横屏,您将使用 Row,并将导航栏和屏幕内容并排放置。

@Composable
fun MySootheAppLandscape() {
   MySootheTheme {
       Row {
           SootheNavigationRail()
           HomeScreen()
       }
   }
}

在纵向版本中使用 Scaffold 时,它也会为您处理将内容颜色设置为背景颜色的问题。要设置 Navigation Rail 的颜色,请将 Row 封装在 Surface 中,并将其背景颜色设置为背景色。

@Composable
fun MySootheAppLandscape() {
   MySootheTheme {
       Surface(color = MaterialTheme.colorScheme.background) {
           Row {
               SootheNavigationRail()
               HomeScreen()
           }
       }
   }
}

e91a0bc068797eec.png

14. MySoothe 应用 - 窗口大小

您的横屏模式预览看起来很棒。但是,如果您在设备或模拟器上运行应用并将其横过来,它不会显示横屏版本。这是因为我们需要告诉应用何时显示哪种配置。为此,使用 calculateWindowSizeClass() 函数来查看手机处于何种配置。

346355a616f580a5.png

有三种窗口大小分类宽度:Compact、Medium 和 Expanded。当应用处于纵向模式时,它是 Compact 宽度;当它处于横屏模式时,它是 Expanded 宽度。对于本 Codelab,您不会使用 Medium 宽度。

在 MySootheApp 可组合函数中,更新它以接收设备的 WindowSizeClass。如果它是 compact,则传入应用的纵向版本。如果它是 landscape,则传入应用的横屏版本。

import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
@Composable
fun MySootheApp(windowSize: WindowSizeClass) {
   when (windowSize.widthSizeClass) {
       WindowWidthSizeClass.Compact -> {
           MySootheAppPortrait()
       }
       WindowWidthSizeClass.Expanded -> {
           MySootheAppLandscape()
       }
   }
}

setContent() 中,创建一个名为 windowSizeClass 的 val 并将其设置为 calculateWindowSize(),然后将其传入 MySootheApp()。

由于 calculateWindowSize() 仍处于实验阶段,您需要选择使用 ExperimentalMaterial3WindowSizeClassApi 类。

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

class MainActivity : ComponentActivity() {
   @OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContent {
           val windowSizeClass = calculateWindowSizeClass(this)
           MySootheApp(windowSizeClass)
       }
   }
}

现在 - 在您的模拟器或设备上运行应用,并观察旋转时显示的变化。

d7f79fd7013d499a.png

94083c1e68a00295.png

15. 恭喜

恭喜,您已成功完成此 Codelab,并对 Compose 中的布局有了更多了解。通过实现一个真实世界的设计,您学习了修饰符、对齐方式、排列方式、Lazy 布局、槽位 API、滚动、Material 组件以及特定布局的设计。

查看Compose 路径上的其他 Codelab。并查看代码示例

文档

如需了解这些主题的更多信息和指导,请查阅以下文档: