Jetpack Compose 中的布局

“Jetpack Compose 基础知识”Codelab 中,您学习了如何使用 Text 等可组合项以及灵活的布局可组合项(如 ColumnRow,可用于在界面上放置列表项,分别为垂直和水平方向),并配置界面中元素的对齐方式,从而利用 Compose 构建简单的界面。另一方面,如果您不想让列表项垂直或水平显示,可以使用 Box 将列表项放在其他列表项的后面和/或前面。

596386b0dceed4f6.png

您可以使用这些标准布局组件构建如下所示的界面:

d2c39f3c2416c321.png

@Composable
fun PhotographerProfile(photographer: Photographer) {
    Row(verticalAlignment = Alignment.CenterVertically) {
        Image(...)
        Column {
            Text(photographer.name)
            Text(photographer.lastSeenOnline, ...)
        }
    }
}

得益于 Compose 的可重用性和可组合性,您可以用新的可组合函数将所需正确抽象级别的不同部分组合起来,构建自己的可组合项。

在本 Codelab 中,您将学习如何使用 Compose 的最高级别界面抽象语言,即 Material Design 以及低级别可组合项(例如 Layout)。使用这些可组合项,您可以测量元素并将它们放置到界面上。

如果您想创建基于 Material Design 的界面,Compose 具有内置 Material 组件可组合项,可供您使用。我们将在本 Codelab 中了解这些可组合项。如果您不想使用 Material Design,或者想构建超出 Material Design 范畴的内容,则还需要学习如何创建自定义布局。

学习内容

在本 Codelab 中,您将学习:

  • 如何使用 Material 组件可组合项
  • 什么是修饰符以及如何在布局中使用它们
  • 如何创建自定义布局
  • 何时可能需要固有特性

前提条件

所需条件

如需启动新的 Compose 项目,请打开 Android Studio Arctic Fox,然后选择 Start a new Android Studio project,如下所示:

dabf04f3abbdc28a.png

如果系统未显示上述界面,请依次转到 File > New > New Project

创建新项目时,请从可用模板中选择 Empty Compose Activity

a67ba73a4f06b7ac.png

点击 Next,然后照常配置项目。确保您选择的 minimumSdkVersion 至少为 API 级别 21,这是 Compose 支持的最低 API 级别。

选择 Empty Compose Activity 模板后,会在项目中为您生成以下代码:

  • 该项目已配置为使用 Compose。
  • AndroidManifest.xml 文件已创建
  • app/build.gradle(或 build.gradle (Module: YourApplicationName.app))文件会导入 Compose 依赖项,并使 Android Studio 能够使用 buildFeatures { compose true } 标记。此外,请确保 composeOptions 块中不存在 kotlinCompilerVersion
android {
    ...
    kotlinOptions {
        jvmTarget = '1.8'
        useIR = true
    }
    buildFeatures {
        compose true
    }
    composeOptions {
        kotlinCompilerExtensionVersion compose_version
        // Remove kotlinCompilerVersion from here
    }
}

dependencies {
    ...
    implementation "androidx.compose.ui:ui:$compose_version"
    implementation 'androidx.activity:activity-compose:1.3.0'
    implementation "androidx.constraintlayout:constraintlayout-compose:1.0.0-beta01"
    implementation "androidx.compose.material:material:$compose_version"
    implementation "androidx.compose.ui:ui-tooling:$compose_version"
    ...
}

本 Codelab 的解决方案

您可以从 GitHub 获取本 Codelab 的解决方案代码:

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

或者,您可以下载代码库 Zip 文件:

下载 Zip 文件

您可以在 LayoutsCodelab 项目中找到解决方案代码。建议您按照自己的节奏逐步完成 Codelab,必要时再查看解决方案。在本 Codelab 中,系统会为您显示需要添加到项目的代码段。

借助修饰符,您可以修饰可组合项。您可以更改其行为、外观,添加无障碍功能标签等信息,处理用户输入,甚至添加高级交互(例如使某些元素可点击、可滚动、可拖动或可缩放)。修饰符是标准的 Kotlin 对象。您可以将它们分配给变量并重复使用,也可以将多个修饰符逐一串联起来,以组合这些修饰符。

下面我们将实现简介部分中介绍的个人资料布局:

d2c39f3c2416c321.png

请打开 MainActivity.kt 并添加以下内容:

@Composable
fun PhotographerCard() {
    Column {
        Text("Alfred Sisley", fontWeight = FontWeight.Bold)
        // LocalContentAlpha is defining opacity level of its children
        CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
            Text("3 minutes ago", style = MaterialTheme.typography.body2)
        }
    }
}

@Preview
@Composable
fun PhotographerCardPreview() {
    LayoutsCodelabTheme {
        PhotographerCard()
    }
}

预览如下:

bf29f2c3f5d6a27.png

接下来,在加载图像时,您可能希望显示占位符。对此,您可以使用 Surface,在其中指定圆形和占位符颜色。如需指定所需占位符大小,可以使用 size 修饰符:

@Composable
fun PhotographerCard() {
    Row {
        Surface(
            modifier = Modifier.size(50.dp),
            shape = CircleShape,
            color = MaterialTheme.colors.onSurface.copy(alpha = 0.2f)
        ) {
            // Image goes here
        }
        Column {
            Text("Alfred Sisley", fontWeight = FontWeight.Bold)
            CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
                Text("3 minutes ago", style = MaterialTheme.typography.body2)
            }
        }
    }
}

84f2bb229d67987b.png

我们希望在以下方面做一些改进:

  1. 我们想让占位符和文本之间有一些间隔。
  2. 我们想让文本垂直居中。

对于第 1 项,我们可以在包含文本的 Column 上使用 Modifier.padding,从而在可组合项的 start 上添加一些空间,用以分隔图像和文本。对于第 2 项,某些布局提供了仅适用于它们本身及其布局特性的修饰符。例如,Row 中的可组合项可以访问某些比较适用的修饰符(来自 Row 内容的 RowScope 接收者),例如 weightalign。这种作用域限制具备类型安全性,因此您不可能会意外使用在其他布局中不适用的修饰符(例如,weightBox 中就不适用),系统会将其视为编译时错误加以阻止。

@Composable
fun PhotographerCard() {
    Row {
        Surface(
            modifier = Modifier.size(50.dp),
            shape = CircleShape,
            color = MaterialTheme.colors.onSurface.copy(alpha = 0.2f)
        ) {
            // Image goes here
        }
        Column(
            modifier = Modifier
                .padding(start = 8.dp)
                .align(Alignment.CenterVertically)
        ) {
            Text("Alfred Sisley", fontWeight = FontWeight.Bold)
            CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
                Text("3 minutes ago", style = MaterialTheme.typography.body2)
            }
        }
    }
}

预览如下:

1542fadc7f68feb2.png

大多数可组合项都接受可选的修饰符参数,以使其更加灵活,从而让调用者能够修改它们。如果您要创建自己的可组合项,不妨考虑使用修饰符作为参数,将其默认设置为 Modifier(即不执行任何操作的空修饰符),并将其应用于函数的根可组合项。在本例中:

@Composable
fun PhotographerCard(modifier: Modifier = Modifier) {
    Row(modifier) { ... }
}

修饰符顺序很重要

请注意,在代码中,您可以使用工厂扩展函数(即 Modifier.padding(start = 8.dp).align(Alignment.CenterVertically))将多个修饰符逐一串联起来。

串联修饰符时请务必小心,因为顺序很重要。由于修饰符会串联成一个参数,所以顺序将影响最终结果。

如果您想使摄影师个人资料可点击并有内边距,则可执行如下操作:

@Composable
fun PhotographerCard(modifier: Modifier = Modifier) {
    Row(modifier
        .padding(16.dp)
        .clickable(onClick = { /* Ignoring onClick */ })
    ) {
        ...
    }
}

使用互动式预览或在模拟器中运行:

c15a1050b051617f.gif

请注意,整个区域是不可点击的!这是因为 padding 是在 clickable 修饰符前面应用的。如果我们在 clickable 后面应用 padding 修饰符,则内边距会添加到可点击区域中:

@Composable
fun PhotographerCard(modifier: Modifier = Modifier) {
    Row(modifier
        .clickable(onClick = { /* Ignoring onClick */ })
        .padding(16.dp)
    ) {
        ...
    }
}

使用互动式预览或在模拟器中运行:

a1ea4c8e16d61ffa.gif

尽情发挥您的想象力吧!修饰符让您可以非常灵活地修改可组合项。例如,如果您想要添加外间距、更改可组合项的背景颜色以及对 Row 的角进行圆化处理,可使用以下代码:

@Composable
fun PhotographerCard(modifier: Modifier = Modifier) {
    Row(modifier
        .padding(8.dp)
        .clip(RoundedCornerShape(4.dp))
        .background(MaterialTheme.colors.surface)
        .clickable(onClick = { /* Ignoring onClick */ })
        .padding(16.dp)
    ) {
        ...
    }
}

使用互动式预览或在模拟器中运行:

4c7652fc71ccf8dc.gif

稍后在本 Codelab 中,我们将深入了解修饰符的工作原理。

Compose 提供了可用于构建界面的高级 Material 组件可组合项。由于这些可组合项是用于创建界面的构建块,因此您仍然需要提供有关要在界面上显示哪些内容的信息。

插槽 API 是 Compose 引入的一种模式,它在可组合项的基础上提供了一层自定义设置,在本例中,提供的是可用的 Material 组件可组合项。

下面我们通过一个示例来了解一下:

如果您想了解 Material Button,我们制定了一套指南,说明了 Button 应具备的外观及其应包含的内容,也可以将它转换为简单的 API 以供使用:

Button(text = "Button")

b3cb99320ec18268.png

但是,您通常希望自定义的组件是我们预想不到的。我们可以尝试为每个可自定义的元素添加一个参数,但情况会很快失控:

Button(
    text = "Button",
    icon: Icon? = myIcon,
    textStyle = TextStyle(...),
    spacingBetweenIconAndText = 4.dp,
    ...
)

ef5893f332864e28.png

因此,我们添加了槽位,而不是以意想不到的方式添加多个参数。槽位会在界面中留出空白区域,让开发者按照自己的意愿来填充。

fccfb817afa8876e.png

例如,对于 Button,我们可以让 Button 的内部由您填充,而您可能希望插入一个包含图标和文本的行:

Button {
    Row {
        MyImage()
        Spacer(4.dp)
        Text("Button")
    }
}

为此,我们提供了一个用于 Button 的 API,该 API 接受可组合项的子 lambda ( content: @Composable () -> Unit)。这样一来,您就可以定义要在 Button 内发出的自定义可组合项。

@Composable
fun Button(
    modifier: Modifier = Modifier,
    onClick: (() -> Unit)? = null,
    ...
    content: @Composable () -> Unit
)

请注意,我们命名为 content 的 lambda 是最后一个参数。这样,我们就能使用尾随 lambda 语法,以结构化方式将内容插入到 Button 中。

在更复杂的组件(如顶部应用栏)中,Compose 会大量使用槽位。

4365ce9b02ec2805.png

现在,我们可以自定义除标题之外的其他内容:

2decc9ec64c79a84.png

用法示例:

TopAppBar(
    title = {
        Text(text = "Page title", maxLines = 2)
    },
    navigationIcon = {
        Icon(myNavIcon)
    }
)

在构建自己的可组合项时,您可以使用插槽 API 模式提高它们的可重用性。

在接下来的部分中,我们将介绍不同的可用 Material 组件可组合项,以及如何在构建 Android 应用时使用这些可组合项。

Compose 附带内置的 Material 组件可组合项,您可以用他们创建应用。最高级别的可组合项是 Scaffold

Scaffold

Scaffold 可让您实现具有基本 Material Design 布局结构的界面。Scaffold 可以为最常见的顶层 Material 组件(例如 TopAppBarBottomAppBarFloatingActionButtonDrawer)提供槽位。使用 Scaffold 时,您可以确保这些组件能够正确放置并协同工作。

我们要根据生成的 Android Studio 模板修改示例代码,以使用 Scaffold。请打开 MainActivity.kt 并放心删除 GreetingGreetingPreview 可组合项,因为不会使用它们。

请创建一个名为 LayoutsCodelab 的新可组合项,我们将在学习本 Codelab 的过程中对其进行修改:

import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.codelab.layouts.ui.LayoutsCodelabTheme

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            LayoutsCodelabTheme {
                LayoutsCodelab()
            }
        }
    }
}

@Composable
fun LayoutsCodelab() {
    Text(text = "Hi there!")
}

@Preview
@Composable
fun LayoutsCodelabPreview() {
    LayoutsCodelabTheme {
        LayoutsCodelab()
    }
}

如果您看到必须用 @Preview 注解的 Compose 预览函数,您会看到如下所示的 LayoutsCodelab

bd1c58d4497f523f.png

我们将 Scaffold 可组合项添加到示例中,以便我们能够使用典型的 Material Design 结构。Scaffold API 中的所有参数都是可选的,但 @Composable (InnerPadding) -> Unit 类型的正文内容除外:lambda 会接收内边距作为参数。这是应该应用于内容根可组合项的内边距,用于在界面上适当地限制列表项。首先,让我们添加不带任何其他 Material 组件的 Scaffold

@Composable
fun LayoutsCodelab() {
    Scaffold { innerPadding ->
        Text(text = "Hi there!", modifier = Modifier.padding(innerPadding))
    }
}

预览如下:

54b175d305766292.png

如果我们想使用含有界面主要内容的 Column,应该将修饰符应用于 Column

@Composable
fun LayoutsCodelab() {
    Scaffold { innerPadding ->
        Column(modifier = Modifier.padding(innerPadding)) {
            Text(text = "Hi there!")
            Text(text = "Thanks for going through the Layouts codelab")
        }
    }
}

预览如下:

aceda77e27f25fe9.png

为了提高代码的可重用性和可测试性,我们应该将其构造为多个小的数据块。为此,我们需要使用界面内容再创建一个可组合函数。

@Composable
fun LayoutsCodelab() {
    Scaffold { innerPadding ->
        BodyContent(Modifier.padding(innerPadding))
    }
}

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    Column(modifier = modifier) {
        Text(text = "Hi there!")
        Text(text = "Thanks for going through the Layouts codelab")
    }
}

通常情况下,Android 应用中会显示一个顶部应用栏,其中包含有关当前界面、导航和操作的信息。现在,把它添加到示例中。

TopAppBar

Scaffold 包含一个顶部应用栏的槽位,其 topBar 参数为 @Composable () -> Unit 类型,这意味着我们可以用任何想要的可组合项填充该槽位。例如,如果我们只希望它包含 h3 样式的文本,则可以在提供的槽位中使用 Text,如下所示:

@Composable
fun LayoutsCodelab() {
    Scaffold(
        topBar = {
            Text(
                text = "LayoutsCodelab",
                style = MaterialTheme.typography.h3
            )
        }
    ) { innerPadding ->
        BodyContent(Modifier.padding(innerPadding))
    }
}

预览如下:

6adf05bb92b48b76.png

不过,与大多数 Material 组件一样,Compose 附带一个 TopAppBar 可组合项,其包含用于标题、导航图标和操作的槽位。此外,它还包含一些默认设置,可以根据 Material 规范的建议(例如要在每个组件上使用的颜色)进行调整。

按照槽 API 模式,我们希望 TopAppBartitle 槽位包含一个带有界面标题的 Text

@Composable
fun LayoutsCodelab() {
    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    Text(text = "LayoutsCodelab")
                }
            )
        }
    ) { innerPadding ->
        BodyContent(Modifier.padding(innerPadding))
    }
}

预览如下:

c93d09851d6560c7.png

顶部应用栏通常包含一些操作项。在本示例中,我们将添加一个收藏夹按钮。当您觉得自己已学会一些内容时,可以点按该按钮。Compose 还带有一些您可以使用的预定义 Material 图标,例如关闭、收藏夹和菜单图标。

顶部应用栏中的操作项槽位为 actions 参数,该参数在内部使用 Row,因此系统会水平放置多个操作。如需使用某个预定义图标,可结合使用 IconButton 可组合项和其中的 Icon

@Composable
fun LayoutsCodelab() {
    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    Text(text = "LayoutsCodelab")
                },
                actions = {
                    IconButton(onClick = { /* doSomething() */ }) {
                        Icon(Icons.Filled.Favorite, contentDescription = null)
                    }
                }
            )
        }
    ) { innerPadding ->
        BodyContent(Modifier.padding(innerPadding))
    }
}

预览如下:

b2d81ccec4667ef5.png

通常,操作会以某种方式修改应用的状态。如需详细了解状态,可在Compose 基础知识 Codelab 中学习状态管理基础知识。

放置修饰符

每当创建新的可组合项时,提高可组合项可重用性的一种最佳做法是使用默认为 Modifiermodifier 参数。BodyContent 可组合项已经接受一个修饰符作为参数。如果要为 BodyContent 再添加一些内边距,应该在什么位置放置 padding 修饰符?

我们有两种选择:

  1. 将修饰符应用于可组合项中唯一的直接子元素,以便所有对 BodyContent 的调用都会应用额外的内边距:
@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    Column(modifier = modifier.padding(8.dp)) {
        Text(text = "Hi there!")
        Text(text = "Thanks for going through the Layouts codelab")
    }
}
  1. 在调用可时需要添加额外内边距的可组合项时,应用修饰符:
@Composable
fun LayoutsCodelab() {
    Scaffold(...) { innerPadding ->
        BodyContent(Modifier.padding(innerPadding).padding(8.dp))
    }
}

确定在何处放置修饰符完全取决于可组合项的类型和用例。如果修饰符是可组合项的固有特性,则将其放置在内部;如果不是,则放置在外部。在本示例中,我们将使用选项 2,因为在调用 BodyContent 时并不一定会强制使用内边距,而是应该根据具体情况加以应用。

可以通过对前一个修饰符调用各个连续修饰符函数的方式,将修饰符串联起来。如果没有可用的串联方法,可以使用 .then()。在本示例中,我们以 modifier(小写)开头,表示该链是基于作为参数传入的链构建的。

更多图标

除了前面列出的图标之外,您还可以在项目中添加新的依赖项,以此使用 Material 图标的完整列表。如果您想试用这些图标,请打开 app/build.gradle(或 build.gradle (Module: app))文件并导入 ui-material-icons-extended 依赖项:

dependencies {
  ...
  implementation "androidx.compose.material:material-icons-extended:$compose_version"
}

接下来继续操作,您也可以随意更改 TopAppBar 的图标。

进一步操作

ScaffoldTopAppBar 只是一些可用于构建 Material 样式应用的可组合项。您可以为其他 Material 组件(例如 BottomNavigationBottomDrawer)执行相同的操作。作为一项练习,我们建议您按照我们之前的操作方式,尝试使用这些 API 来填充 Scaffold 槽位。

显示项列表是应用的常见模式。Jetpack Compose 可让您使用 ColumnRow 可组合项轻松实现此模式,但它还提供了仅应用于编写和布局当前可见项的延迟列表。

让我们练习一下,使用 Column 可组合项创建一个包含 100 项的垂直列表:

@Composable
fun SimpleList() {
    Column {
        repeat(100) {
            Text("Item #$it")
        }
    }
}

默认情况下,Column 不会处理滚动操作,某些项是看不到的,因为它们在界面范围外。请添加 verticalScroll 修饰符,以在 Column 内启用滚动:

@Composable
fun SimpleList() {
    // We save the scrolling position with this state that can also
    // be used to programmatically scroll the list
    val scrollState = rememberScrollState()

    Column(Modifier.verticalScroll(scrollState)) {
        repeat(100) {
            Text("Item #$it")
        }
    }
}

延迟列表

Column 会渲染所有列表项,甚至包括界面上看不到的项,当列表大小变大时,这会造成性能问题。为避免此问题,请使用 LazyColumn,它只会渲染界面上的可见项,因而有助于提升性能,而且无需使用 scroll 修饰符。

LazyColumn 具有一个 DSL,用于描述其列表内容。您将使用 items,它会接受一个数字作为列表大小。它还支持数组和列表(如需了解详情,请参阅列表文档部分)。

@Composable
fun LazyList() {
    // We save the scrolling position with this state that can also
    // be used to programmatically scroll the list
    val scrollState = rememberLazyListState()

    LazyColumn(state = scrollState) {
        items(100) {
            Text("Item #$it")
        }
    }
}

931742d3086b49f9.gif

显示图像

就像我们之前了解的 PhotographCard 内容一样,Image 是一个可组合项,用于显示位图或矢量图像。如果图像是远程获取的,则该过程涉及的步骤会更多,因为应用需要下载资源,将之解码为位图,最后在 Image 中进行渲染。

如需简化这些步骤,可以使用 Coil 库,它提供了能够高效运行这些任务的可组合项。

将 Coil 依赖项添加到项目的 build.gradle 文件中:

// build.gradle
implementation 'io.coil-kt:coil-compose:1.3.0'

由于我们要提取远程图像,请在清单文件中添加 INTERNET 权限:

<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.INTERNET" />

现在,请创建一个项可组合项,您将在其中显示一张旁边有项索引的图像:

@Composable
fun ImageListItem(index: Int) {
    Row(verticalAlignment = Alignment.CenterVertically) {

        Image(
            painter = rememberImagePainter(
                data = "https://developer.android.com/images/brand/Android_Robot.png"
            ),
            contentDescription = "Android Logo",
            modifier = Modifier.size(50.dp)
        )
        Spacer(Modifier.width(10.dp))
        Text("Item #$index", style = MaterialTheme.typography.subtitle1)
    }
}

接下来,将列表中的 Text 可组合项替换为此 ImageListItem

@Composable
fun ImageList() {
    // We save the scrolling position with this state
    val scrollState = rememberLazyListState()

    LazyColumn(state = scrollState) {
        items(100) {
            ImageListItem(it)
        }
    }
}

d1cffa197a8479d6.gif

列表滚动

现在,我们来手动控制列表的滚动位置。我们需要添加两个按钮,以便顺畅地滚动到列表顶部或底部。为避免在滚动时阻止列表呈现,滚动 API 属于挂起函数。因此,我们需要在协程中调用它们。如需实现此目的,可使用 rememberCoroutineScope 函数创建 CoroutineScope,以从按钮事件处理脚本创建协程。此 CoroutineScope 将遵循调用点的生命周期。如需详细了解可组合生命周期、协程和附带效应,请参阅此指南

val listSize = 100
// We save the scrolling position with this state
val scrollState = rememberLazyListState()
// We save the coroutine scope where our animated scroll will be executed
val coroutineScope = rememberCoroutineScope()

最后,我们要添加用于控制滚动的按钮:

Row {
    Button(onClick = {
        coroutineScope.launch {
            // 0 is the first item index
            scrollState.animateScrollToItem(0)
        }
    }) {
        Text("Scroll to the top")
    }

    Button(onClick = {
        coroutineScope.launch {
            // listSize - 1 is the last index of the list
            scrollState.animateScrollToItem(listSize - 1)
        }
    }) {
        Text("Scroll to the end")
    }
}

59d4da7b7c982ffa.gif

此部分的完整代码

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Button
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import coil.compose.rememberImagePainter
import kotlinx.coroutines.launch

@Composable
fun ImageListItem(index: Int) {
    Row(verticalAlignment = Alignment.CenterVertically) {
        Image(
            painter = rememberImagePainter(
                data = "https://developer.android.com/images/brand/Android_Robot.png"
            ),
            contentDescription = "Android Logo",
            modifier = Modifier.size(50.dp)
        )
        Spacer(Modifier.width(10.dp))
        Text("Item #$index", style = MaterialTheme.typography.subtitle1)
    }
}

@Composable
fun ScrollingList() {
    val listSize = 100
    // We save the scrolling position with this state
    val scrollState = rememberLazyListState()
    // We save the coroutine scope where our animated scroll will be executed
    val coroutineScope = rememberCoroutineScope()

    Column {
        Row {
            Button(onClick = {
                coroutineScope.launch {
                    // 0 is the first item index
                    scrollState.animateScrollToItem(0)
                }
            }) {
                Text("Scroll to the top")
            }

            Button(onClick = {
                coroutineScope.launch {
                    // listSize - 1 is the last index of the list
                    scrollState.animateScrollToItem(listSize - 1)
                }
            }) {
                Text("Scroll to the end")
            }
        }

        LazyColumn(state = scrollState) {
            items(listSize) {
                ImageListItem(it)
            }
        }
    }
}

Compose 提高了这些像小模块一样的可组合项的可重用性,您可以组合各种内置可组合项(例如 ColumnRowBox),充分满足某些自定义布局的需求。

不过,您可能需要为应用构建一些独特内容,构建时需要手动测量和布置子元素。对此,可以使用 Layout 可组合项。实际上,ColumnRow 等所有较高级别的布局都是基于此构建的。

在深入了解如何创建自定义布局之前,我们需要详细了解 Compose 中的布局原则。

Compose 中的布局原则

某些可组合函数在被调用后会发出一部分界面,这部分界面会添加到将呈现到界面上的界面树中。每次发送(或每个元素)都有一个父元素,还可能有多个子元素。此外,它在父元素中具有位置 (x, y) 和大小,即 widthheight

要求元素使用其应满足的约束条件进行自我测量。约束条件可限制元素的最小和最大 widthheight。如果某个元素有子元素,它可能会测量每个元素,以帮助确定它自己的大小。一旦某个元素报告了自己的大小,就有机会相对于自身放置它的子元素。创建自定义布局时,我们将对此做进一步解释说明。

Compose 界面不允许多遍测量。这意味着,布局元素不能为了尝试不同的测量配置而多次测量任何子元素。单遍测量对性能有利,使 Compose 能够高效地处理较深的界面树。如果某个布局元素测量了它的子元素两次,而该子元素又测量了它的一个子元素两次,依此类推,那么一次尝试布置整个界面就不得不做大量的工作,这使得很难让应用保持良好的性能。不过,有时除了子元素的单遍测量告知您的信息之外,您确实还需要一些额外的信息 - 对于这些情况,有一些解决方法,我们稍后会做介绍。

使用布局修饰符

使用 layout 修饰符可手动控制如何测量和定位元素。通常,自定义 layout 修饰符的常见结构如下:

fun Modifier.customLayoutModifier(...) = Modifier.layout { measurable, constraints ->
  ...
})

使用 layout 修饰符时,您会获得两个 lambda 参数:

  • measurable:要测量和放置的子元素
  • constraints:子元素宽度和高度的最小值和最大值

假设您要在界面上显示 Text,并控制从顶部到第一行文本基线的距离。为实现此目的,您需要使用 layout 修饰符,将可组合项手动放置到界面上。您可以在下图中了解所需的行为,其中,从顶部到第一条基线的距离为 24.dp

4ee1054702073598.png

我们先创建一个 firstBaselineToTop 修饰符:

fun Modifier.firstBaselineToTop(
  firstBaselineToTop: Dp
) = this.then(
    layout { measurable, constraints ->
        ...
    }
)

首先要测量可组合项。正如我们在“Compose 中的布局原则”部分中所述,只能测量子元素一次

通过调用 measurable.measure(constraints) 测量可组合项。调用 measure(constraints) 时,您可以传入 constraints lambda 参数中提供的可组合项的给定约束条件,也可以自行创建约束条件。对 Measurable 进行 measure() 调用的结果为 Placeable,它可以通过调用 placeRelative(x, y) 来放置。稍后我们将执行此操作。

对于此用例,请不要进一步限制测量,只需使用指定的约束条件即可:

fun Modifier.firstBaselineToTop(
    firstBaselineToTop: Dp
) = this.then(
    layout { measurable, constraints ->
        val placeable = measurable.measure(constraints)

        ...
    }
)

现在已经测量该可组合项,您需要计算其大小,然后调用 layout(width, height) 方法(该方法也接受用于放置内容的 lambda)来指定其大小。

在本例中,可组合项的宽度将是被测可组合项的 width,高度将是该可组合项的 height(所需的顶部到基线高度减去第一条基线):

fun Modifier.firstBaselineToTop(
    firstBaselineToTop: Dp
) = this.then(
    layout { measurable, constraints ->
        val placeable = measurable.measure(constraints)

        // Check the composable has a first baseline
        check(placeable[FirstBaseline] != AlignmentLine.Unspecified)
        val firstBaseline = placeable[FirstBaseline]

        // Height of the composable with padding - first baseline
        val placeableY = firstBaselineToTop.roundToPx() - firstBaseline
        val height = placeable.height + placeableY
        layout(placeable.width, height) {
            ...
        }
    }
)

现在,您可以调用 placeable.placeRelative(x, y),在界面上放置可组合项。如果您不调用 placeRelative,该可组合项将不可见。placeRelative 会根据当前的 layoutDirection 自动调整可放置位置。

在本例中,文本的 y 位置对应于上内边距减去第一条基线的位置:

fun Modifier.firstBaselineToTop(
    firstBaselineToTop: Dp
) = this.then(
    layout { measurable, constraints ->
        ...

        // Height of the composable with padding - first baseline
        val placeableY = firstBaselineToTop.roundToPx() - firstBaseline
        val height = placeable.height + placeableY
        layout(placeable.width, height) {
            // Where the composable gets placed
            placeable.placeRelative(0, placeableY)
        }
    }
)

如需验证这段代码是否可以发挥预期的作用,可以对 Text 使用此修饰符,如上图所示:

@Preview
@Composable
fun TextWithPaddingToBaselinePreview() {
  LayoutsCodelabTheme {
    Text("Hi there!", Modifier.firstBaselineToTop(32.dp))
  }
}

@Preview
@Composable
fun TextWithNormalPaddingPreview() {
  LayoutsCodelabTheme {
    Text("Hi there!", Modifier.padding(top = 32.dp))
  }
}

预览如下:

dccb4473e2ca09c6.png

使用布局可组合项

您可能无需控制单个可组合项的测量和布局方式,而可能需要的是针对一组可组合项实施此操作。为此,您可以使用 Layout 可组合项手动控制如何测量和定位布局的子元素。通常,使用 Layout 的可组合项的常见结构如下所示:

@Composable
fun CustomLayout(
    modifier: Modifier = Modifier,
    // custom layout attributes
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // measure and position children given constraints logic here
    }
}

CustomLayout 至少需要 modifiercontent 参数;这些参数随后会传递到 Layout。在 LayoutMeasurePolicy 类型)的尾随 lambda 中,您获取的 lambda 参数与使用 layout 修饰符获取的类型为 lambda 参数相同。

为了展示 Layout 的实际运用,让我们使用 Layout 实现一个非常基本的 Column,以便了解该 API。稍后我们会构建更复杂的内容,以展示 Layout 可组合项的灵活性。

实现基本 Column

Column 的自定义实现垂直布局列表项。此外,为简单起见,布局在父元素中也占用了尽可能多的空间

请创建一个名为 MyOwnColumn 的新可组合项,然后添加 Layout 可组合项的常见结构:

@Composable
fun MyOwnColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // measure and position children given constraints logic here
    }
}

和以前一样,首先要做的就是测量子元素,而子元素只能测量一次。与布局修饰符的工作原理类似,在 measurables lambda 参数中,您可以通过调用 measurable.measure(constraints) 获取所有可测量的 content

对于此用例,无需进一步限制子视图。在测量子元素时,还应跟踪每行的 width 和最大 height,以便稍后能将它们正确放置到界面上:

@Composable
fun MyOwnColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->

        // Don't constrain child views further, measure them with given constraints
        // List of measured children
        val placeables = measurables.map { measurable ->
            // Measure each child
            measurable.measure(constraints)
        }
    }
}

现在,逻辑中包含了已测量子元素的列表,在将它们放置到界面上之前,您需要计算 Column 的大小。将大小设为与父元素一样大时,其大小即为父元素传递的约束条件。请调用 layout(width, height) 方法指定您自己的 Column 的大小,该方法还会为您提供用于放置子元素的 lambda:

@Composable
fun MyOwnColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // Measure children - code in the previous code snippet
        ...

        // Set the size of the layout as big as it can
        layout(constraints.maxWidth, constraints.maxHeight) {
            // Place children
        }
    }
}

最后,我们通过调用 placeable.placeRelative(x, y) 将子元素放置到界面上。如需垂直放置子元素,需要跟踪放置子元素的 y 坐标。MyOwnColumn 的最终代码如下所示:

@Composable
fun MyOwnColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // Don't constrain child views further, measure them with given constraints
        // List of measured children
        val placeables = measurables.map { measurable ->
            // Measure each child
            measurable.measure(constraints)
        }

        // Track the y co-ord we have placed children up to
        var yPosition = 0

        // Set the size of the layout as big as it can
        layout(constraints.maxWidth, constraints.maxHeight) {
            // Place children in the parent layout
            placeables.forEach { placeable ->
                // Position item on the screen
                placeable.placeRelative(x = 0, y = yPosition)

                // Record the y co-ord placed up to
                yPosition += placeable.height
            }
        }
    }
}

MyOwnColumn 的实际运用

我们通过在界面上使用 BodyContent 可组合项来查看 MyOwnColumn。请将 BodyContent 中的内容替换为以下内容:

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    MyOwnColumn(modifier.padding(8.dp)) {
        Text("MyOwnColumn")
        Text("places items")
        Text("vertically.")
        Text("We've done it by hand!")
    }
}

预览如下:

e69cdb015e4d8abe.png

Layout 的基础知识已介绍完毕。我们来创建一个更复杂的示例,以展示 API 的灵活性。我们要构建自定义的 Material Study Owl 交错网格,如下图所示:

7a54fe8390fe39d2.png

Owl 的交错网格垂直排列列表项,在给定 n 行的情况下,一次填充一列。不能对 Columns 的某一个 Row 执行此操作,因为这样无法实现交错布局。如果您准备好数据以使其垂直显示,就可以对 Rows 的 某一个 Column 执行此操作。

不过,自定义布局还让您能够约束交错网格中所有列表项的高度。因此,为了更好地控制布局并了解如何创建自定义布局,我们将自行测量和定位子元素。

如果要让网格可在不同方向上重复使用,可以使用参数作为想要在界面上包含的行数。由于该信息应该是在调用布局时出现的,因此我们将其以参数的形式进行传递:

@Composable
fun StaggeredGrid(
    modifier: Modifier = Modifier,
    rows: Int = 3,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // measure and position children given constraints logic here
    }
}

和之前一样,首先要做的是测量子元素。请注意,子元素只能测量一次

对于本用例,无需进一步限制子视图。在测量子元素时,还应跟踪每行的 width 和最大 height

Layout(
    modifier = modifier,
    content = content
) { measurables, constraints ->

    // Keep track of the width of each row
    val rowWidths = IntArray(rows) { 0 }

    // Keep track of the max height of each row
    val rowHeights = IntArray(rows) { 0 }

    // Don't constrain child views further, measure them with given constraints
    // List of measured children
    val placeables = measurables.mapIndexed { index, measurable ->

        // Measure each child
        val placeable = measurable.measure(constraints)

        // Track the width and max height of each row
        val row = index % rows
        rowWidths[row] += placeable.width
        rowHeights[row] = max(rowHeights[row], placeable.height)

        placeable
    }
    ...
}

现在,逻辑中包含了已测量的子元素列表,在将它们放置到界面上之前,我们需要计算网格的大小(完整的 widthheight)。此外,由于我们已经知道每行的最大高度,因此我们可以计算每行中的元素在 Y 轴上的位置。将 Y 位置保存在 rowY 变量中:

Layout(
    content = content,
    modifier = modifier
) { measurables, constraints ->
    ...

    // Grid's width is the widest row
    val width = rowWidths.maxOrNull()
        ?.coerceIn(constraints.minWidth.rangeTo(constraints.maxWidth)) ?: constraints.minWidth

    // Grid's height is the sum of the tallest element of each row
    // coerced to the height constraints
    val height = rowHeights.sumOf { it }
        .coerceIn(constraints.minHeight.rangeTo(constraints.maxHeight))

    // Y of each row, based on the height accumulation of previous rows
    val rowY = IntArray(rows) { 0 }
    for (i in 1 until rows) {
        rowY[i] = rowY[i-1] + rowHeights[i-1]
    }

    ...
}

最后,我们通过调用 placeable.placeRelative(x, y) 将子元素放置到界面上。在本用例中,我们还跟踪了 rowX 变量中每一行的 X 坐标:

Layout(
    content = content,
    modifier = modifier
) { measurables, constraints ->
    ...

    // Set the size of the parent layout
    layout(width, height) {
        // x cord we have placed up to, per row
        val rowX = IntArray(rows) { 0 }

        placeables.forEachIndexed { index, placeable ->
            val row = index % rows
            placeable.placeRelative(
                x = rowX[row],
                y = rowY[row]
            )
            rowX[row] += placeable.width
        }
    }
}

在示例中使用自定义 StaggeredGrid

现在,我们已经拥有知道如何测量和定位子元素的自定义网格布局,所以让我们在应用中运用该布局。为了在网格中模拟 Owl 的信息块,我们可以轻松创建一个执行类似操作的可组合项:

@Composable
fun Chip(modifier: Modifier = Modifier, text: String) {
    Card(
        modifier = modifier,
        border = BorderStroke(color = Color.Black, width = Dp.Hairline),
        shape = RoundedCornerShape(8.dp)
    ) {
        Row(
            modifier = Modifier.padding(start = 8.dp, top = 4.dp, end = 8.dp, bottom = 4.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Box(
                modifier = Modifier.size(16.dp, 16.dp)
                    .background(color = MaterialTheme.colors.secondary)
            )
            Spacer(Modifier.width(4.dp))
            Text(text = text)
        }
    }
}

@Preview
@Composable
fun ChipPreview() {
    LayoutsCodelabTheme {
        Chip(text = "Hi there")
    }
}

预览如下:

f1f8c6bb7f12cf1.png

现在,我们来创建一个主题列表,该列表可在 BodyContent 中显示,并在 StaggeredGrid 中显示:

val topics = listOf(
    "Arts & Crafts", "Beauty", "Books", "Business", "Comics", "Culinary",
    "Design", "Fashion", "Film", "History", "Maths", "Music", "People", "Philosophy",
    "Religion", "Social sciences", "Technology", "TV", "Writing"
)

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    StaggeredGrid(modifier = modifier) {
        for (topic in topics) {
            Chip(modifier = Modifier.padding(8.dp), text = topic)
        }
    }
}

@Preview
@Composable
fun LayoutsCodelabPreview() {
    LayoutsCodelabTheme {
        BodyContent()
    }
}

预览如下:

e9861768e4e27dd4.png

请注意,我们可以更改网格的行数,而且它仍然可以发挥预期作用:

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    StaggeredGrid(modifier = modifier, rows = 5) {
        for (topic in topics) {
            Chip(modifier = Modifier.padding(8.dp), text = topic)
        }
    }
}

预览如下:

555f88fd41e4dff4.png

取决于行数多少,主题可以超出界面。我们可以将 BodyContent 设置为可滚动,只需将 StaggeredGrid 封装在一个可滚动的 Row 中,并向其传递修饰符即可,而不是 StaggeredGrid

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    Row(modifier = modifier.horizontalScroll(rememberScrollState())) {
        StaggeredGrid {
            for (topic in topics) {
                Chip(modifier = Modifier.padding(8.dp), text = topic)
            }
        }
    }
}

如果您使用的是 Interactive Preview 按钮 bb4c8dfe4b8debaa.png,或通过点按 Android Studio 运行按钮在设备上运行应用,您会看到如何水平滚动内容。

至此我们已经了解了修饰符的基础知识,也了解了如何创建自定义可组合项并手动测量和定位子元素。接下来,我们将更好地详细了解修饰符的工作原理。

回顾一下,修饰符允许您自定义可组合项的行为。您可以将多个修饰符串联在一起,以组合修饰符。修饰符有多种类型,但在此部分中重点介绍的是 LayoutModifier,因为它们可以改变界面组件的测量和布局方式。

可组合项对其自己的内容负责,并且父元素不能检查或操纵该内容,除非该可组合项的发布者公开了明确的 API 来执行此操作。同样,可组合项的修饰符会以相同的不透明方式修饰它们:对修饰符进行封装。

分析修饰符

由于 ModifierLayoutModifier 是公共接口,因此您可以创建自己的修饰符。正如我们之前使用了 Modifier.padding 一样,让我们来分析它的实现,以便更好地了解修饰符。

padding 是一个由实现 LayoutModifier 接口的类提供支持的函数,而且该函数将替换 measure 方法。PaddingModifier 是一个实现 equals() 的标准类,因此可以在重组后比较该修饰符。

例如,下面显示的是有关 padding 如何修改元素的大小和约束条件的源代码:

// How to create a modifier
@Stable
fun Modifier.padding(all: Dp) =
    this.then(
        PaddingModifier(start = all, top = all, end = all, bottom = all, rtlAware = true)
    )

// Implementation detail
private class PaddingModifier(
    val start: Dp = 0.dp,
    val top: Dp = 0.dp,
    val end: Dp = 0.dp,
    val bottom: Dp = 0.dp,
    val rtlAware: Boolean,
) : LayoutModifier {

    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {

        val horizontal = start.roundToPx() + end.roundToPx()
        val vertical = top.roundToPx() + bottom.roundToPx()

        val placeable = measurable.measure(constraints.offset(-horizontal, -vertical))

        val width = constraints.constrainWidth(placeable.width + horizontal)
        val height = constraints.constrainHeight(placeable.height + vertical)
        return layout(width, height) {
            if (rtlAware) {
                placeable.placeRelative(start.roundToPx(), top.roundToPx())
            } else {
                placeable.place(start.roundToPx(), top.roundToPx())
            }
        }
    }
}

元素的新 width 将是子元素的 width + 强制转换为元素宽度约束条件的起始和结束内边距值。height 将是子元素的 height + 强制转换为元素高度约束条件的上下内边距值。

顺序很重要

如第一部分中所述,串联修饰符时顺序非常重要,因为这些修饰符会从前到后应用于所修改的可组合项,这意味着左侧修饰符的测量值和布局会影响右侧的修饰符。可组合项的最终大小取决于作为参数传递的所有修饰符。

首先,修饰符会从左到右更新约束条件,然后从右到左返回大小。下面我们通过一个示例来更好地了解这一点:

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    Row(
        modifier = modifier
            .background(color = Color.LightGray)
            .size(200.dp)
            .padding(16.dp)
            .horizontalScroll(rememberScrollState())
    ) {
        StaggeredGrid {
            for (topic in topics) {
                Chip(modifier = Modifier.padding(8.dp), text = topic)
            }
        }
    }
}

以这种方式应用的修饰符会生成以下预览:

cb209bb5edf634d6.png

首先,我们更改背景,了解修饰符对界面有何影响;接下来,将大小限制为 200.dp widthheight;最后,应用内边距,在文本和周围内容之间添加一些空间。

由于约束条件在链中是从左到右传播的,因此对要测量的 Row 的内容采用的约束条件为:widthheight 的最大值和最小值都为 (200-16-16)=168 dp。这意味着,StaggeredGrid 的大小正好为 168x168 dp。因此,在 modifySize 链从右向左运行后,可滚动的 Row 的最终大小为 200x200 dp。

如果我们更改修饰符的顺序,先应用内边距,然后再应用大小,则会得到不同的界面:

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    Row(
        modifier = modifier
            .background(color = Color.LightGray, shape = RectangleShape)
            .padding(16.dp)
            .size(200.dp)
            .horizontalScroll(rememberScrollState())
    ) {
        StaggeredGrid {
            for (topic in topics) {
                Chip(modifier = Modifier.padding(8.dp), text = topic)
            }
        }
    }
}

预览如下:

17da5805d6d8fc91.png

在这种情况下,可滚动的 Rowpadding 最初具有的约束条件将被强制转换为 size 约束条件,以测量子元素。因此,对于最小和最大 width 以及 heightStaggeredGrid 将被约束为 200 dp。StaggeredGrid 大小为 200x200 dp,而随着从右向左修改大小,padding 修饰符会将大小增大为 (200+16+16)x(200+16+16)=232x232,这也就是 Row 的最终大小。

布局方向

您可以使用 LayoutDirection 环境更改可组合项的布局方向。

如果您要将可组合项手动放置到界面上,则 layoutDirectionlayout 修饰符或 Layout 可组合项的 LayoutScope 的一部分。与使用 placeRelative 方法不同,在使用 layoutDirection 时,应使用 place 放置可组合项,它不会在从右到左的上下文中自动镜像位置。

此部分的完整代码

import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.codelab.layouts.ui.LayoutsCodelabTheme
import kotlin.math.max

val topics = listOf(
    "Arts & Crafts", "Beauty", "Books", "Business", "Comics", "Culinary",
    "Design", "Fashion", "Film", "History", "Maths", "Music", "People", "Philosophy",
    "Religion", "Social sciences", "Technology", "TV", "Writing"
)

@Composable
fun LayoutsCodelab() {
    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    Text(text = "LayoutsCodelab")
                },
                actions = {
                    IconButton(onClick = { /* doSomething() */ }) {
                        Icon(Icons.Filled.Favorite, contentDescription = null)
                    }
                }
            )
        }
    ) { innerPadding ->
        BodyContent(Modifier.padding(innerPadding))
    }
}

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    Row(modifier = modifier
        .background(color = Color.LightGray)
        .padding(16.dp)
        .size(200.dp)
        .horizontalScroll(rememberScrollState()),
        content = {
            StaggeredGrid {
                for (topic in topics) {
                    Chip(modifier = Modifier.padding(8.dp), text = topic)
                }
            }
        })
}

@Composable
fun StaggeredGrid(
    modifier: Modifier = Modifier,
    rows: Int = 3,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->

        // Keep track of the width of each row
        val rowWidths = IntArray(rows) { 0 }

        // Keep track of the max height of each row
        val rowHeights = IntArray(rows) { 0 }

        // Don't constrain child views further, measure them with given constraints
        // List of measured children
        val placeables = measurables.mapIndexed { index, measurable ->
            // Measure each child
            val placeable = measurable.measure(constraints)

            // Track the width and max height of each row
            val row = index % rows
            rowWidths[row] += placeable.width
            rowHeights[row] = max(rowHeights[row], placeable.height)

            placeable
        }

        // Grid's width is the widest row
        val width = rowWidths.maxOrNull()
            ?.coerceIn(constraints.minWidth.rangeTo(constraints.maxWidth)) ?: constraints.minWidth

        // Grid's height is the sum of the tallest element of each row
        // coerced to the height constraints
        val height = rowHeights.sumOf { it }
            .coerceIn(constraints.minHeight.rangeTo(constraints.maxHeight))

        // Y of each row, based on the height accumulation of previous rows
        val rowY = IntArray(rows) { 0 }
        for (i in 1 until rows) {
            rowY[i] = rowY[i - 1] + rowHeights[i - 1]
        }

        // Set the size of the parent layout
        layout(width, height) {
            // x co-ord we have placed up to, per row
            val rowX = IntArray(rows) { 0 }

            placeables.forEachIndexed { index, placeable ->
                val row = index % rows
                placeable.placeRelative(
                    x = rowX[row],
                    y = rowY[row]
                )
                rowX[row] += placeable.width
            }
        }
    }
}

@Composable
fun Chip(modifier: Modifier = Modifier, text: String) {
    Card(
        modifier = modifier,
        border = BorderStroke(color = Color.Black, width = Dp.Hairline),
        shape = RoundedCornerShape(8.dp)
    ) {
        Row(
            modifier = Modifier.padding(start = 8.dp, top = 4.dp, end = 8.dp, bottom = 4.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Box(
                modifier = Modifier
                    .size(16.dp, 16.dp)
                    .background(color = MaterialTheme.colors.secondary)
            )
            Spacer(Modifier.width(4.dp))
            Text(text = text)
        }
    }
}

@Preview
@Composable
fun ChipPreview() {
    LayoutsCodelabTheme {
        Chip(text = "Hi there")
    }
}

@Preview
@Composable
fun LayoutsCodelabPreview() {
    LayoutsCodelabTheme {
        LayoutsCodelab()
    }
}

ConstraintLayout 有助于依据可组合项的相对位置将它们放置到界面上,是使用多个 RowColumnBox 元素的替代方案。在实现对齐要求比较复杂的较大布局时,ConstraintLayout 很有用。

您可以在项目的 build.gradle 文件中找到 Compose Constraint Layout 依赖项:

// build.gradle
implementation "androidx.constraintlayout:constraintlayout-compose:1.0.0-alpha07"

Compose 中的 ConstraintLayout 支持 DSL

  • 引用是使用 createRefs()(或 createRef())创建的,ConstraintLayout 中的每个可组合项都需要有与之关联的引用。
  • 约束条件是使用 constrainAs 修饰符提供的,该修饰符将引用作为参数,可让您在主体 lambda 中指定其约束条件。
  • 约束条件是使用 linkTo 或其他有用的方法指定的。
  • parent 是一个现有的引用,可用于指定对 ConstraintLayout 可组合项本身的约束条件。

我们先从一个简单的例子开始:

@Composable
fun ConstraintLayoutContent() {
    ConstraintLayout {

        // Create references for the composables to constrain
        val (button, text) = createRefs()

        Button(
            onClick = { /* Do something */ },
            // Assign reference "button" to the Button composable
            // and constrain it to the top of the ConstraintLayout
            modifier = Modifier.constrainAs(button) {
                top.linkTo(parent.top, margin = 16.dp)
            }
        ) {
            Text("Button")
        }

        // Assign reference "text" to the Text composable
        // and constrain it to the bottom of the Button composable
        Text("Text", Modifier.constrainAs(text) {
            top.linkTo(button.bottom, margin = 16.dp)
        })
    }
}

@Preview
@Composable
fun ConstraintLayoutContentPreview() {
    LayoutsCodelabTheme {
        ConstraintLayoutContent()
    }
}

此代码使用 16.dp 的外边距来约束 Button 顶部到父元素的距离,同样使用 16.dp 的外边距来约束 TextButton 底部的距离。

72fcb81ab2c0483c.png

如果希望文本水平居中,可以使用 centerHorizontallyTo 函数将 Textstartend 均设置为 parent 的边缘:

@Composable
fun ConstraintLayoutContent() {
    ConstraintLayout {
        ... // Same as before

        Text("Text", Modifier.constrainAs(text) {
            top.linkTo(button.bottom, margin = 16.dp)
            // Centers Text horizontally in the ConstraintLayout
            centerHorizontallyTo(parent)
        })
    }
}

预览如下:

729a1b4c03f1f187.png

ConstraintLayout 会尽可能小,以封装其内容。这就是 Text 似乎以 Button 而非父元素为中心的原因所在。如果需要其他大小调整行为,应将大小调整修饰符(例如 fillMaxSizesize)应用于 ConstraintLayout 可组合项,就像 Compose 中的任何其他布局一样。

帮助程序

DSL 还支持创建准则、限制和链。例如:

@Composable
fun ConstraintLayoutContent() {
    ConstraintLayout {
        // Creates references for the three composables
        // in the ConstraintLayout's body
        val (button1, button2, text) = createRefs()

        Button(
            onClick = { /* Do something */ },
            modifier = Modifier.constrainAs(button1) {
                top.linkTo(parent.top, margin = 16.dp)
            }
        ) {
            Text("Button 1")
        }

        Text("Text", Modifier.constrainAs(text) {
            top.linkTo(button1.bottom, margin = 16.dp)
            centerAround(button1.end)
        })

        val barrier = createEndBarrier(button1, text)
        Button(
            onClick = { /* Do something */ },
            modifier = Modifier.constrainAs(button2) {
                top.linkTo(parent.top, margin = 16.dp)
                start.linkTo(barrier)
            }
        ) {
            Text("Button 2")
        }
    }
}

预览如下:

a4117576ef1768a2.png

请注意:

  • 限制(以及所有其他帮助程序)可以在 ConstraintLayout 的正文中创建,但不能在 constrainAs 内部创建。
  • linkTo 可用于约束准则和限制,就像它运用于布局边缘的工作原理一样。

自定义维度

默认情况下,系统将允许 ConstraintLayout 的子项选择封装其内容所需的大小。例如,这意味着当文本过长时,可以超出界面边界:

@Composable
fun LargeConstraintLayout() {
    ConstraintLayout {
        val text = createRef()

        val guideline = createGuidelineFromStart(fraction = 0.5f)
        Text(
            "This is a very very very very very very very long text",
            Modifier.constrainAs(text) {
                linkTo(start = guideline, end = parent.end)
            }
        )
    }
}

@Preview
@Composable
fun LargeConstraintLayoutPreview() {
    LayoutsCodelabTheme {
        LargeConstraintLayout()
    }
}

616c19b971811cfa.png

很明显,您希望在可用空间中让文本换行。为此,我们可以更改文本的 width 行为:

@Composable
fun LargeConstraintLayout() {
    ConstraintLayout {
        val text = createRef()

        val guideline = createGuidelineFromStart(0.5f)
        Text(
            "This is a very very very very very very very long text",
            Modifier.constrainAs(text) {
                linkTo(guideline, parent.end)
                width = Dimension.preferredWrapContent
            }
        )
    }
}

预览如下:

fc41cacd547bbea.png

可用的 Dimension 行为如下:

  • preferredWrapContent - 布局是封装内容,受限于该维度的约束条件。
  • wrapContent - 布局是封装内容,即使约束条件不允许该内容。
  • fillToConstraints - 布局将展开,以填充由该维度的约束条件定义的空间。
  • preferredValue - 布局是固定的 dp 值,受限于该维度的约束条件。
  • value - 布局是固定的 dp 值,无论该维度中的约束条件如何

此外,某些 Dimension 可以强制转换:

width = Dimension.preferredWrapContent.atLeast(100.dp)

Decoupled API

到目前为止,示例中都是以内嵌方式指定约束条件,同时在它们应用到的可组合项中使用修饰符。不过,在某些情况下,使约束条件与它们所应用到的布局分离会非常有用:常见的示例为根据界面配置轻松更改约束条件,或在 2 个约束条件集之间添加动画效果。

对于这些情况,可以通过不同的方式使用 ConstraintLayout

  1. ConstraintSet 作为参数传递给 ConstraintLayout
  2. 使用 layoutId 修饰符将在 ConstraintSet 中创建的引用分配给可组合项。

此 API 形状适用于上面显示的第一个 ConstraintLayout 示例,它针对界面宽度进行了优化,如下所示:

@Composable
fun DecoupledConstraintLayout() {
    BoxWithConstraints {
        val constraints = if (maxWidth < maxHeight) {
            decoupledConstraints(margin = 16.dp) // Portrait constraints
        } else {
            decoupledConstraints(margin = 32.dp) // Landscape constraints
        }

        ConstraintLayout(constraints) {
            Button(
                onClick = { /* Do something */ },
                modifier = Modifier.layoutId("button")
            ) {
                Text("Button")
            }

            Text("Text", Modifier.layoutId("text"))
        }
    }
}

private fun decoupledConstraints(margin: Dp): ConstraintSet {
    return ConstraintSet {
        val button = createRefFor("button")
        val text = createRefFor("text")

        constrain(button) {
            top.linkTo(parent.top, margin= margin)
        }
        constrain(text) {
            top.linkTo(button.bottom, margin)
        }
    }
}

Compose 有一项规则,即,子元素只能测量一次,测量两次就会引发运行时异常。但是,有时需要先收集一些关于子元素的信息,然后再测量子项。

借助固有特性,您可以先查询子元素,然后再进行实际测量。

对于可组合项,您可以查询其 intrinsicWidthintrinsicHeight

  • (min|max)IntrinsicWidth:给定此高度,可以正确绘制内容的最小/最大宽度是多少。
  • (min|max)IntrinsicHeight:给定此宽度,可以正确绘制内容的最小/最大高度是多少。

例如,如果您查询具有无限 widthTextminIntrinsicHeight,它将返回 Textheight,就好像该文本是在单行中绘制的一样。

固有特性的实际运用

假设我们需要创建一个可组合项,该可组合项在界面上显示两个用分隔线隔开的文本,如下所示:

835f0b8c9f07cd9.png

我们该怎么做?我们可以将两个 Text 放在同一 Row,并在其中最大程度地扩展,另外在中间放置一个 Divider。我们需要将分隔线的高度设置为与最高的 Text 相同,粗细设置为 width = 1.dp

@Composable
fun TwoTexts(modifier: Modifier = Modifier, text1: String, text2: String) {
    Row(modifier = modifier) {
        Text(
            modifier = Modifier
                .weight(1f)
                .padding(start = 4.dp)
                .wrapContentWidth(Alignment.Start),
            text = text1
        )

        Divider(color = Color.Black, modifier = Modifier.fillMaxHeight().width(1.dp))
        Text(
            modifier = Modifier
                .weight(1f)
                .padding(end = 4.dp)
                .wrapContentWidth(Alignment.End),

            text = text2
        )
    }
}

@Preview
@Composable
fun TwoTextsPreview() {
    LayoutsCodelabTheme {
        Surface {
            TwoTexts(text1 = "Hi", text2 = "there")
        }
    }
}

预览时,我们发现分隔线扩展到整个界面,这并不是我们想要的效果:

d61f179394ded825.png

之所以出现这种情况,是因为 Row 会逐个测量每个子元素,并且 Text 的高度不能用于限制 Divider。我们希望 Divider 以一个给定的高度来填充可用空间。为此,我们可以使用 height(IntrinsicSize.Min) 修饰符。

height(IntrinsicSize.Min) 可将其子元素的高度强行调整为最小固有高度。由于该修饰符具有递归性,因此它将查询 Row 及其子项 minIntrinsicHeight

将其应用到代码中,就能达到预期的效果

@Composable
fun TwoTexts(modifier: Modifier = Modifier, text1: String, text2: String) {
    Row(modifier = modifier.height(IntrinsicSize.Min)) {
        Text(
            modifier = Modifier
                .weight(1f)
                .padding(start = 4.dp)
                .wrapContentWidth(Alignment.Start),
            text = text1
        )

        Divider(color = Color.Black, modifier = Modifier.fillMaxHeight().width(1.dp))
        Text(
            modifier = Modifier
                .weight(1f)
                .padding(end = 4.dp)
                .wrapContentWidth(Alignment.End),
            text = text2
        )
    }
}

@Preview
@Composable
fun TwoTextsPreview() {
    LayoutsCodelabTheme {
        Surface {
            TwoTexts(text1 = "Hi", text2 = "there")
        }
    }
}

预览如下:

835f0b8c9f07cd9.png

行的 minIntrinsicHeight 将作为其子项的最大 minIntrinsicHeight。分隔线的 minIntrinsicHeight 为 0,因为如果没有给出约束条件,它不会占用任何空间;如果给出特定 widthminIntrinsicHeight 将为文本的高度。因此,Row 的 height 约束条件将为 Text 的最大 minIntrinsicHeight,而 Divider 会将其 height 扩展为 Row 给定的 height 约束条件。

DIY

每当您创建自定义布局时,都可以使用 (min|max)Intrinsic(Width|Height)MeasurePolicy 参数修改固有特性的计算方式;但在大多数情况下,默认值即可满足需求。

此外,您还可以使用修改修饰符来修改固有特性,这些修饰符会替换 Modifier 接口的 Density.(min|max)Intrinsic(Width|Height)Of 方法,而该接口也具有出色的默认设置。

恭喜,您已成功完成本 Codelab!

本 Codelab 的解决方案

您可以从 GitHub 获取本 Codelab 的解决方案代码:

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

或者,您可以下载代码库 Zip 文件:

下载 Zip 文件

后续操作

请查看 Compose 衔接课程中的其他 Codelab。

深入阅读

示例应用

  • 用于创建自定义布局的 Owl
  • 用于展示图表和表格的 Rally
  • 使用自定义布局的 Jetsnack