Compose 中的基本布局

1. 简介

Compose 是一个界面工具包,可让您轻松实现应用的设计。您只需描述自己想要的界面外观,Compose 会负责在屏幕上进行绘制。在此 Codelab 中,您将了解如何编写 Compose 界面。本文假定您了解基础知识 Codelab 中介绍的概念,因此请务必先完成该 Codelab。在基础知识 Codelab 中,您学习了如何使用 SurfacesRowsColumns 实现简单布局。您还使用 paddingfillMaxWidthsize 等修饰符扩充了这些布局。

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

在此 Codelab 中,您不用向应用添加任何实际行为。如需改为了解状态和互动,请改为完成“在 Compose 中使用状态”Codelab

如果您在学习此 Codelab 的过程中需要获得更多支持,请查看以下配套学习代码:

学习内容

在此 Codelab 中,您将学习:

  • 如何借助修饰符扩充可组合项?
  • 如何通过 Column 和 LazyRow 等标准布局组件定位可组合子项?
  • 如何通过对齐方式和排列方式更改可组合子项在父项中的位置?
  • 如何借助 Scaffold 和 Bottom Navigation 等 Material 可组合项创建详细布局?
  • 如何使用槽位 API 构建灵活的可组合项?
  • 如何为不同的屏幕配置构建布局?

所需条件

构建内容

在此 Codelab 中,您将根据设计人员提供的模拟实现真实的应用设计。MySoothe 是一款健康应用,其中列出了可改善身心健康的各种方法。这款应用包含两个版块,一个列出了您的收藏合集,另一个列出了各种体育锻炼。该应用如下所示:

应用的纵向版本

应用的横向版本

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. 从制定计划着手

首先实现应用的纵向设计。下面我们来详细了解一下:

纵向设计

在需要实现设计时,最好先清楚了解其结构。不要立即开始编码,而应分析设计本身。如何将此界面拆分为多个可重复利用的部分

接下来,我们试着分析一下设计。在最高抽象级别,我们可以将此设计细分为两部分:

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

应用设计细分

进一步细分后,您可以看到屏幕内容包含三个子部分:

  • 搜索栏。
  • “Align your body”版块。
  • “Favorite Collections”版块。

应用设计细分

在每个版块中,您还可以看到一些可重复利用的较低级别组件:

  • “Align your body”元素,显示在可水平滚动的行中。

“Align your body”元素

  • “Favorite Collections”卡片,显示在可水平滚动的网格中。

“Favorite Collections”卡片

现在,您已经分析了设计,接下来可以开始为每个已确定的界面部分实现可组合项。先从最低级别的可组合项着手,然后继续将它们组合成更复杂的可组合项。完成此 Codelab 后,您的新应用将与所提供的设计相似。

4. 搜索栏 - 修饰符

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

搜索栏

只通过上面的屏幕截图,很难在实现该设计时让像素完美呈现。通常情况下,设计人员应传达更多关于设计的信息。他们可以授权您访问他们的设计工具,或分享所谓的用红线标注的设计。在此示例中,我们的设计人员提交了用红线标注的设计,方便您读出任何尺寸值。该设计采用 8dp 网格叠加层的方式显示,以便您轻松查看各个元素之间和周围的空间大小。此外,还明确添加了一些间距,以阐明特定尺寸。

搜索栏红线

您可以看到,搜索栏的高度应为 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 可组合项,并提供一些背景和内边距,以便提供更多上下文。完成您刚才添加的实现后,预览应如下所示:

搜索栏预览

不过,还缺少一些内容。首先,我们使用修饰符修正可组合项的尺寸。

编写可组合项时,您可以使用修饰符执行以下操作:

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

您调用的每个可组合项都有一个 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 的一些形参。您可以试着设置形参值,以使可组合项类似于设计。同样,以下设计供您参考:

搜索栏

更新实现时应采取以下步骤:

  • 添加搜索图标。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)
   )
}

搜索栏

请注意:

  • 您添加了一个用于显示搜索图标的 leadingIcon。此图标不需要内容说明,因为文本字段的占位符已经说明了文本字段的含义。请注意,内容说明通常用于实现无障碍功能,以文本形式向应用用户呈现图片或图标。
  • 如需调整文本字段的背景颜色,请设置 colors 属性。该可组合项包含一个组合形参,而不是每种颜色的单独形参。在这种情况下,您可以传入 TextFieldDefaults 数据类的副本,从而通过该类仅更新不同的颜色。在此示例中,即仅更新 unfocusedContainerColorfocusedContainerColor 颜色。

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

5. Align your body - 对齐

接下来,您要实现的可组合项是“Align your body”元素。我们来看看该元素的设计,包括它旁边的红线设计:

“Align your body”组件

“Align your body”红线

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

  • 图片高度应为 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”预览

您需要进行一些改进。最值得注意的是,图片过大,且形状不是圆形。您可以使用 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”预览

图片也需要正确缩放。为此,我们可以使用 ImagecontentScale 形参。具体选项有很多,最值得注意的是:

“Align your body”内容预览

在此示例中,剪裁类型就是要使用的正确类型。应用修饰符和形参后,您的代码应如下所示:

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”预览

接下来,通过设置 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”红线

执行完这些步骤后,您的代码应如下所示:

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。

“Align your body”预览

6. “Favorite collection”卡片 - Material Surface

下一个可组合项的实现方式与“Align your body”元素类似。设计(包括红线)如下所示:

“Favorite Collections”卡片

“Favorite Collections”卡片红线

此示例提供了可组合项的完整尺寸。您可以看到,文本的排版样式应为 titleMedium。

此容器使用 surfaceVariant 作为其背景颜色,不同于整个屏幕的背景。它还带有圆角。我们使用 Material 的 Surface 可组合项为“Favorite Collections”卡片指定这些属性。

您可以根据自己的需求调整 Surface,只需设置其形参和修饰符即可。在此示例中,表面应有圆角。针对这种情况,您可以使用 shape 形参。对于上一步中的图片,您将形状设置为 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))
       }
   }
}

我们来看一下此实现的预览:

“Favorite Collections”预览

接下来,运用在上一步中学到的经验。

  • 设置 Row 的宽度,并与其子项垂直对齐。
  • 根据图表设置图片尺寸,然后在容器中对其进行剪裁。

“Favorite Collections”红线

先尝试自行实施这些更改,然后再查看解决方案代码!

现在,您的代码如下所示:

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 Collections”预览

如需完成此可组合项,请实施以下步骤:

  • 将图片和文本变为动态形式。将它们作为实参传入可组合函数。
  • 将颜色更新为 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 Collections”预览

7. “Align your body”行 - 排列

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

先从“Align your body”可滚动行着手。

“Align your body”可滚动

此组件的红线设计如下所示:

“Align your body”红线

请注意,一个网格块代表 8dp。因此,在此设计中,该行中的第一项内容前和最后一项内容后均留有 16dp 的间距。各项内容之间的间距为 8dp。

在 Compose 中,您可以使用 LazyRow 可组合项实现这种可滚动行。如需详细了解延迟列表(例如 LazyRowLazyColumn),请参阅有关列表的文档。在此 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 的子项不是可组合项。您应改用延迟列表 DSL,它可提供 itemitems 等方法,这些方法会以列表项的形式发出可组合项。对于所提供的 alignYourBodyData 中的各项,您可以发出之前实现的 AlignYourBodyElement 可组合项。

请注意显示方式:

“Align your body”预览

我们在红线设计中看到的间距仍未显示。若要实现这些部分,您必须了解排列方式

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

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

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

行排列方式

对于 Column

列排列方式

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

此外,您需要在 LazyRow 两侧添加一定尺寸的内边距。在此示例中,添加一个简单的内边距修饰符并不能达到目的。请尝试向 LazyRow 添加内边距,并使用互动式预览看看其行为方式:

“Align your body”红线

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

为了保持相同的内边距,同时确保在父级列表的边界内滚动内容时内容不会被截断,所有列表都需向 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”可滚动

8. “Favorite collections”网格 - 延迟网格

接下来,您要实现的是屏幕的“Favorite collections”版块。这个可组合项需要的是网格,而不是单个行:

“Favorite Collections”滚动

您可以按照与上一部分类似的方式实现此版块,具体方法是创建一个 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 即可。不过,这样还无法得到正确的结果:

“Favorite Collections”预览

网格占用的空间与其父项相同,这意味着,“Favorite Collections”卡片会在垂直方向被过度拉伸。

调整可组合项,以便实现如下效果:

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

最终代码应如下所示:

@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”预览

9. 首页部分 - 槽位 API

在 MySoothe 主屏幕中,有多个版块都遵循同一模式。每个版块都有一个标题,其中包含的内容因版块而异。我们想要实现的红线设计如下所示:

首页部分红线

如您所见,每个版块都有一个标题和一个槽位。标题包含一些与其相关的间距和样式信息。可以使用不同的内容动态填充槽位,具体取决于版块。

如需实现这个灵活的版块容器,您可以使用所谓的槽位 API。在进行这项实现之前,请先阅读文档页面上有关基于槽位的布局的部分。这有助于您了解什么是基于槽位的布局,以及如何使用槽位 API 构建此类布局。

调整 HomeSection 可组合项以接收标题和槽位内容。您还应调整关联的预览,以调用这个包含“Align your body”标题及相关内容的 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 的 TopAppBartitlenavigationIconactions 提供槽位。

我们来看一下这个版块采用该实现后的效果:

首页部分预览

Text 可组合项需要更多信息才能与设计保持一致。

首页部分红线

更新可组合项,以便实现如下效果:

  • 采用 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. 主屏幕 - 滚动

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

您尝试实现的设计如下所示:

首页部分红线

只需依序放置搜索栏和这两个版块。您需要添加一定尺寸的间距,以确保一切都契合设计。我们之前没有使用过 Spacer 可组合项,它可帮助我们在 Column 中添加额外的间距。如果您改为设置 Column 的内边距,便会看到之前在“Favorite Collections”网格中出现的相同截断行为。

@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 等延迟布局会自动添加滚动行为。但是,您不一定总是需要延迟布局。一般来说,在列表中有许多元素或需要加载大型数据集时,您需要使用延迟布局,因此一次发出所有项不仅会降低性能,还会拖慢应用的运行速度。如果列表中的元素数量有限,您也可以选择使用简单的 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 而言,用户可以通过导航栏在不同的屏幕之间切换。

首先实现导航栏可组合项,然后将其添加到应用中。

下面我们来看一下设计:

底部导航栏设计

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

其基本实现过程如下所示(内容颜色和导航栏颜色的反差不明显):

底部导航栏预览

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

现在,导航栏应如下所示(请注意此设置带来的颜色反差如何):

底部导航栏设计

12. MySoothe 应用 - Scaffold

在这一步中,创建全屏实现,包含底部导航栏。使用 Material 的 Scaffold 可组合项。对于实现 Material Design 的应用,Scaffold 提供了可配置的顶级可组合项。它包含可用于各种 Material 概念的槽位,其中一个就是底部栏。在此底部栏中,您可以放置在上一步中创建的底部导航栏可组合项。

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

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

最终结果应如下所示:

import androidx.compose.material3.Scaffold

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

您的实现现已完成!如果想检查您实现的设计能否让像素完美呈现,可以将此图片与您自己的预览实现结果进行比较。

我的 MySoothe 实现结果

13. 导航栏 - Material

为应用创建布局时,您还需要注意它在手机的多个配置(包括横屏模式)下的显示效果。下面是应用在横屏模式下的设计,请注意底部导航栏是如何变成屏幕内容左侧的侧边栏的。

横向设计

如需实现此设计,您不仅要使用 Compose Material 库中的 NavigationRail 可组合项,还要有与用于创建底部导航栏的 NavigationBar 类似的实现方案。在 NavigationRail 可组合项内,您需要为主屏幕和个人资料添加 NavigationRailItem 元素。

底部导航栏设计

我们先从导航栏的基本实现入手。

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

导航栏预览

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

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

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

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

导航栏设计

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

横向设计

对于应用的纵向版本,您使用的是 Scaffold。不过,在横向模式下,您需要使用 Row,并将导航栏和屏幕内容并排放置。

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

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

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

横向预览

14. MySoothe 应用 - 窗口大小

横屏模式的预览效果很理想。不过,如果您在设备或模拟器上运行应用时将其切换到横向模式,系统并不会显示横向版本。这是因为我们需要通知应用何时显示应用的哪个配置。为此,请使用 calculateWindowSizeClass() 函数查看手机当前所用的配置。

窗口大小示意图

窗口大小类别分为三种宽度:较小、中等和较大。应用处于竖屏模式时,使用较小宽度;处于横屏模式时,使用较大宽度。在此 Codelab 中,不会用到中等宽度。

在 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 的值,并将其设置为 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)
       }
   }
}

现在,请在模拟器或设备上运行应用,并观察显示内容在旋转时如何变化。

应用的纵向版本

应用的横向版本

15. 恭喜

恭喜您,您已成功完成了此 Codelab,并详细了解了 Compose 中的布局。通过实现真实的设计,您了解了修饰符、对齐方式、排列方式、延迟布局、槽位 API、滚动行为、Material 组件以及布局专用设计。

请查看 Compose 开发者在线课程中的其他 Codelab。您还可以查看代码示例

文档

如需获得关于上述主题的更多信息和指导,请参阅以下文档: