Compose 中的基本布局

1. 简介

作为 UI 工具包,Compose 使实现应用设计变得容易。您描述 UI 的外观,Compose 负责在屏幕上绘制它。本 Codelab 教您如何编写 Compose UI。它假设您了解 基础知识 Codelab 中教授的概念,因此请确保您首先完成该 Codelab。在基础知识 Codelab 中,您学习了如何使用 SurfacesRowsColumns 实现简单的布局。您还使用 paddingfillMaxWidthsize 等修饰符增强了这些布局。

在本 Codelab 中,您将实现一个更真实且复杂的布局,并在此过程中了解各种开箱即用的可组合项修饰符。完成本 Codelab 后,您应该能够将基本应用的设计转换为可工作的代码。

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

在您完成本 Codelab 的过程中,请查看以下代码随附内容以获取更多支持

您将学到什么

在本 Codelab 中,您将学习

  • 修饰符如何帮助您增强可组合项。
  • Column 和 LazyRow 等标准布局组件如何定位子可组合项。
  • 对齐方式和排列方式如何更改子可组合项在其父级中的位置。
  • Scaffold 和 Bottom Navigation 等 Material 可组合项如何帮助您创建全面的布局。
  • 如何使用插槽 API 构建灵活的可组合项。
  • 如何为不同的屏幕配置构建布局。

您需要什么

您将构建什么

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

Portrait version of app

Landscape version of app

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. 从计划开始

我们将首先实现应用的纵向设计 - 让我们仔细看看

portrait design

当您被要求实现某个设计时,一个好的开始方法是清楚地了解其结构。不要立即开始编码,而是分析设计本身。如何将此 UI 拆分为多个可重用的部分

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

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

app design breakdown

深入了解,屏幕内容包含三个子部分

  • 搜索栏。
  • 一个名为“调整您的身体”的部分。
  • 一个名为“收藏夹”的部分。

app design breakdown

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

  • 以水平可滚动行显示的“调整您的身体”元素。

align your body element

  • 以水平可滚动网格显示的“收藏夹”卡片。

favorite collection card

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

4. 搜索栏 - 修饰符

第一个要转换为可组合项的元素是搜索栏。让我们再看看设计

search bar

仅根据此屏幕截图,以像素完美的方式实现此设计将非常困难。通常,设计师会传达更多关于设计的信息。他们可以授予您访问其设计工具的权限,或共享所谓的红线设计。在本例中,我们的设计师提供了红线设计,您可以使用它们读取任何尺寸值。设计显示带有 8dp 网格叠加层,因此您可以轻松地看到元素之间和周围有多少空间。此外,还明确添加了一些间距以阐明某些尺寸。

search bar redline

您可以看到搜索栏的高度应为 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,因此您将忽略与状态相关的所有内容。
  • SearchBar 可组合函数接受一个 modifier 参数并将其传递给 TextField。根据 Compose 指南,这是一个最佳实践。这允许方法的调用者修改可组合项的外观和行为,从而使其更灵活且更易于重用。您将在本 Codelab 中的所有可组合项中继续遵循此最佳实践。

让我们看看此可组合项的预览。请记住,您可以使用 Android Studio 中的 预览功能 快速迭代您的各个可组合项。 MainActivity.kt 包含您将在本 Codelab 中构建的所有可组合项的预览。在本例中,方法 SearchBarPreview 呈现我们的 SearchBar 可组合项,并带有一些背景和填充以使其具有更多上下文。使用您刚刚添加的实现,它应该如下所示

search bar preview

缺少一些东西。首先,让我们使用 修饰符 修复可组合项的大小。

编写可组合项时,您使用修饰符

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

您调用的每个可组合项都有一个 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的参数。尝试通过设置参数值使可组合项看起来像设计图。以下是设计图以供参考

search bar

以下是您应该采取的更新实现步骤

  • 添加搜索图标。TextField包含一个参数leadingIcon,它接受另一个可组合项。在其中,您可以设置一个Icon,在我们的例子中应该是Search图标。确保使用正确的Compose Icon导入。
  • 您可以使用TextFieldDefaults.textFieldColors覆盖特定颜色。将文本字段的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)
   )
}

search bar

注意

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

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

5. 对齐您的身体 - 对齐

您将实现的下一个可组合项是“对齐您的身体”元素。让我们看一下它的设计,包括它旁边的红线设计

align your body component

align your body redline

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

  • 图像应为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可组合项中提供的参数,以使其动态化。

查看此可组合项的预览

align your body preview

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

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

目前,您在预览中的设计如下所示

align your body preview

图像也需要正确缩放。为此,我们可以使用ImagecontentScale参数。有几个选项,最值得注意的是

align your body content preview

在这种情况下,裁剪类型是正确的选择。应用修饰符和参数后,您的代码应如下所示

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

您的设计现在应该如下所示

align your body preview

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

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

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

  • Start
  • CenterHorizontally
  • End

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

  • Top
  • CenterVertically
  • Bottom

对于Box,您将水平和垂直对齐方式结合起来。选项包括

  • TopStart
  • TopCenter
  • TopEnd
  • CenterStart
  • Center
  • CenterEnd
  • BottomStart
  • BottomCenter
  • BottomEnd

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

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

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

在实现这些部分后,您只需要进行一些小的更改即可使可组合项与设计完全相同。尝试自己实现这些更改,或者如果遇到问题,请参考最终代码。考虑以下步骤

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

align your body redline

完成这些步骤的实现后,您的代码应类似于此

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

查看“设计”选项卡中的AlignYourBodyElement。

align your body preview

6. 收藏夹卡片 - Material Surface

下一个要实现的可组合项在某种程度上类似于“对齐身体”元素。以下是设计图,包括红线

favorite collection card

favorite collection card redline

在这种情况下,提供了可组合项的完整大小。您可以看到文本应为titleMedium。

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

您可以通过设置其参数和修饰符来根据需要调整Surface。在这种情况下,surface应具有圆角。您可以为此使用shape参数。您将使用来自Material主题的值,而不是像上一步中的Image那样将形状设置为Shape

让我们看看它是什么样子

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

让我们看看此实现的预览

favorite collection preview

接下来,应用上一步中学习的经验教训。

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

favorite collection redline

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

您的代码现在应该如下所示

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

预览现在应该如下所示

favorite collection preview

要完成此可组合项,请执行以下步骤

  • 使图像和文本动态化。将它们作为参数传递给可组合函数。
  • 将颜色更新为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的预览。

favorite collection preview

7. 对齐您的身体行 - 布局

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

从“调整你的身体”的可滚动行开始。

align your body scrollable

以下是此组件的红线设计

align your body redline

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

在 Compose 中,您可以使用 LazyRow 可组合项来实现这样的可滚动行。有关列表的文档包含有关 LazyRowLazyColumn 等 Lazy 列表的更多信息。对于此代码实验室,您只需要知道 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 的子项不是可组合项。相反,您使用提供诸如 itemitems 等方法的 Lazy 列表 DSL,这些方法将可组合项作为列表项发出。对于提供的 alignYourBodyData 中的每个项目,您都会发出之前实现的 AlignYourBodyElement 可组合项。

注意它是如何显示的

align your body preview

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

在之前的步骤中,您了解了对齐方式,这些对齐方式用于在**交叉轴**上对齐容器的子项。对于 Column,交叉轴是水平轴,而对于 Row,交叉轴是垂直轴。

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

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

row arrangements

以及对于 Column

col arrangements

除了这些排列方式之外,您还可以使用 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)
       }
   }
}

现在设计如下所示

align your body preview

您还需要在 LazyRow 的两侧添加一些填充。在这种情况下,添加简单的填充修饰符不起作用。尝试向 LazyRow 添加填充,并使用交互式预览查看它的行为。

align your body redline

如您所见,在滚动时,第一个和最后一个可见项目在屏幕的两侧都被裁剪掉了。

要保持相同的填充,但仍然在父列表的范围内滚动内容而不会裁剪它,所有列表都向 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)
       }
   }
}

尝试交互式预览以查看填充带来的差异。

align your body scrollable

8. 收藏夹网格 - Lazy 网格

接下来要实现的部分是屏幕的“收藏夹”部分。此可组合项需要一个网格,而不是单行。

favorite collections scrolling

您可以通过创建 LazyRow 并让每个项目包含两个 FavoriteCollectionCard 实例来类似地实现此部分。但是,在此步骤中,您将使用 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。但是,这还不会为您提供正确的结果

favorite collections preview

网格占据与其父级一样多的空间,这意味着收藏夹卡片在垂直方向上被拉伸得太多。

调整可组合项,以便

  • 网格具有 16.dp 的水平内容填充。
  • 水平和垂直排列之间的间距为 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))
       }
   }
}

预览应如下所示

favorite collections preview

9. 首页 - 插槽 API

在 MySoothe 主屏幕上,有多个遵循相同模式的**部分**。它们每个都有一个标题,一些内容根据部分而有所不同。以下是我们要实现的红线设计

home section redline

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

要实现此灵活的部分容器,您将使用所谓的插槽 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 的插槽。

让我们看看使用此实现的部分是什么样子

home section preview

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

home section redline

更新它,以便

  • 它使用 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. 首页 - 滚动

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

以下是您尝试实现的设计

home section redline

我们只是将搜索栏和下面的两个部分一个接一个地放置。您需要添加一些间距以使所有内容都适合设计。我们之前没有使用过的一种可组合项是 Spacer,它可以帮助我们在 Column 中放置额外的空间。如果您改为设置 Column 的填充,则会得到之前在收藏夹网格中看到的相同裁剪行为。

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

screen content scroll

11. 底部导航 - Material

现在您已经实现了屏幕的内容,您就可以添加窗口装饰了。在 MySoothe 的情况下,有一个导航栏允许用户在不同的屏幕之间切换。

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

让我们看看设计

bottom navigation design

幸运的是,您不必从头开始实现整个可组合项。您可以使用 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 = {}
       )
   }
}

基本实现如下所示 - 内容颜色和导航栏颜色之间没有太多对比。

bottom navigation preview

您应该进行一些样式调整。首先,您可以通过设置其 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 = {}
       )
   }
}

现在导航栏应该如下所示,请注意它提供了更多对比度。

bottom navigation design

12. MySoothe 应用 - 脚手架

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

实现 MySootheAppPortrait() 可组合项。这是您应用的顶级可组合项,因此您应该

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

您的最终结果应该是

import androidx.compose.material3.Scaffold

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

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

my soothe implementation

13. 导航栏 - Material

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

landscape design

要实现此功能,您将使用 Compose Material 库中的一部分 NavigationRail 可组合项,其实现与用于创建底部导航栏的 NavigationBar 类似。在 NavigationRail 可组合项内部,您将添加 NavigationRailItem 元素以用于“主页”和“个人资料”。

bottom navigation design

让我们从导航栏的基本实现开始。

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

navigation rail preview

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

  • 在栏的开始和结束处添加 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 = {}
           )
       }
   }
}

navigation rail design

现在,让我们将导航栏添加到横向布局中。

landscape design

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

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

当您在纵向版本中使用 Scaffold 时,它还会负责为您设置内容颜色为背景。要设置导航栏的颜色,请将 Row 包裹在 Surface 中并将其设置为背景颜色。

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

landscape preview

14. MySoothe 应用 - 窗口大小

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

window size diagram

窗口大小类宽度有三种:紧凑型、中型和扩展型。当应用处于纵向模式时,其宽度为紧凑型,当应用处于横向模式时,其宽度为扩展型。出于此代码实验室的目的,您将不会使用中型宽度。

在 MySootheApp 可组合项中,将其更新为接收设备的 WindowSizeClass。如果它是紧凑型,则传入应用的纵向版本。如果是横向,则传入应用的横向版本。

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

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

Portrait version of app

Landscape version of app

15. 祝贺您

恭喜您,您已成功完成此代码实验室,并了解了有关 Compose 中布局的更多信息。通过实现现实世界的设计,您了解了修饰符、对齐方式、排列、懒惰布局、插槽 API、滚动、Material 组件和特定于布局的设计。

查看 Compose 学习路径 上的其他代码实验室。并查看 代码示例

文档

有关这些主题的更多信息和指南,请查看以下文档