Compose for Wear OS 的列表功能


在 Wear OS 设备上,列表用于提供一组选项来供用户选择。

许多 Wear OS 设备都使用圆形屏幕,用户不方便查看靠近屏幕顶部和底部的列表项。因此,Compose for Wear OS 提供了一个名为 TransformingLazyColumnLazyColumn 类版本,该版本支持缩放和形变动画。当有列表项移至屏幕边缘时,便会缩小并淡出。

如需应用建议的缩放和滚动效果,请执行以下操作:

  1. 使用 Modifier.transformedHeight,让 Compose 计算列表项在屏幕上滚动时的高度变化。
  2. 使用 transformation = SurfaceTransformation(transformationSpec) 应用视觉效果,包括缩小列表项内容。
  3. 对于不使用 transformation 作为参数的组件(例如 Text),请使用自定义 TransformationSpec

以下动画演示了列表元素在接近屏幕顶部和底部时如何缩放和改变形状:

以下代码段展示了如何使用 TransformingLazyColumn 布局创建在各种 Wear OS 屏幕尺寸上看起来都很棒的内容。

此代码段还演示了 minimumVerticalContentPadding 修饰符的用法,您应在列表项上设置该修饰符,以便在列表顶部和底部应用正确的内边距。

如需显示滚动指示器,请在 ScreenScaffoldTransformingLazyColumn 之间共享 columnState

val columnState = rememberTransformingLazyColumnState()
val transformationSpec = rememberTransformationSpec()
ScreenScaffold(
    scrollState = columnState
) { contentPadding ->
    TransformingLazyColumn(
        state = columnState,
        contentPadding = contentPadding
    ) {
        item {
            ListHeader(
                modifier = Modifier
                    .fillMaxWidth()
                    .transformedHeight(this, transformationSpec)
                    .minimumVerticalContentPadding(ListHeaderDefaults.minimumTopListContentPadding),
                transformation = SurfaceTransformation(transformationSpec)
            ) {
                Text(text = "Header")
            }
        }
        // ... other items
        item {
            Button(
                modifier = Modifier
                    .fillMaxWidth()
                    .transformedHeight(this, transformationSpec)
                    .minimumVerticalContentPadding(ButtonDefaults.minimumVerticalListContentPadding),
                transformation = SurfaceTransformation(transformationSpec),
                onClick = { /* ... */ },
                icon = {
                    Icon(
                        imageVector = Icons.Default.Build,
                        contentDescription = "build",
                    )
                },
            ) {
                Text(
                    text = "Build",
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis,
                )
            }
        }
    }
}

添加贴靠和快速滑动效果

贴靠可确保当用户完成滚动或快速滑动操作后,列表会停留在某个项精确位于特定点(通常是屏幕中心)的位置。在圆形屏幕上,当项目远离中心时,会进行缩放和变形,因此贴靠功能非常有用,可确保最相关的项目在最佳观看区域内保持完全可见和可读。

如需添加贴靠和轻拂行为,请将 flingBehavior 参数设置为 TransformingLazyColumnDefaults.snapFlingBehavior(columnState)。将 rotaryScrollableBehavior 设置为匹配,使用 RotaryScrollableDefaults.snapBehavior(columnState) 可在使用实体表冠或表圈时获得一致的体验。

val columnState = rememberTransformingLazyColumnState()
ScreenScaffold(scrollState = columnState) { contentPadding ->
    TransformingLazyColumn(
        state = columnState,
        flingBehavior = TransformingLazyColumnDefaults.snapFlingBehavior(columnState),
        rotaryScrollableBehavior = RotaryScrollableDefaults.snapBehavior(columnState)
    ) {
        // ...
        // ...
    }
}

反向布局

默认情况下,可滚动列表会锚定到其顶部边缘。如果用户已滚动到标准列表的底部,并且在末尾添加了新项,则列表会保持用户对当前项的视图。例如,如果用户正在查看屏幕底部的商品 10,并且添加了商品 11,则视图仍会聚焦于商品 10,而商品 11 会显示在当前视图下方的屏幕外。

对于消息传递应用或实时日志等使用情形,通常不希望出现此行为。当新内容到达时,如果用户已位于列表底部,他们通常希望立即看到最新内容。如果一次到达许多商品,列表应跳过中间的商品,直接显示底部的最新商品(这意味着,除非用户向上滚动,否则可能根本不会显示某些中间商品)。

为了支持这些使用情形,TransformingLazyColumn 允许您通过设置 reverseLayout = true 来反转布局。这样会将列表的锚点从顶部边缘更改为底部边缘。

为方便起见,设置 reverseLayout = true 还会反转商品的视觉顺序和滚动手势的方向:

  • 项目从底部到顶部组成,这意味着索引 0 显示在屏幕底部。
  • 向上滚动可显示索引较高的项。

如需添加贴靠和轻拂行为以及反向布局,您可以组合使用 flingBehaviorrotaryScrollableBehavior,如以下代码段所示:

val columnState = rememberTransformingLazyColumnState()
val transformationSpec = rememberTransformationSpec()
ScreenScaffold(scrollState = columnState) { contentPadding ->
    TransformingLazyColumn(
        state = columnState,
        contentPadding = contentPadding,
        reverseLayout = true,
        modifier = Modifier.fillMaxWidth()
    ) {
        items(10) { index ->
            Button(
                label = {
                    Text(
                        text = "Item ${index + 1}"
                    )
                },
                onClick = {},
                modifier = Modifier
                    .fillMaxWidth()
                    .transformedHeight(this, transformationSpec)
                    .minimumVerticalContentPadding(ButtonDefaults.minimumVerticalListContentPadding),
                transformation = SurfaceTransformation(transformationSpec)
            )
        }
        item {
            // With reverseLayout = true, the last item declared appears at the top.
            ListHeader(
                modifier = Modifier
                    .fillMaxWidth()
                    .transformedHeight(this, transformationSpec)
                    .minimumVerticalContentPadding(ListHeaderDefaults.minimumTopListContentPadding),
                transformation = SurfaceTransformation(transformationSpec)
            ) {
                Text("Header")
            }
        }
    }
}

以下图片展示了普通列表与反向列表之间的区别:

具有正常布局的 TransformingLazyColumn,显示了顶部的 Item 1 和按升序排列的项。
图 1. 一种标准列表布局,内容从上到下填充。
具有反向布局的 TransformingLazyColumn,底部显示“项目 1”,项目按降序排列,越往上数字越大。
图 2. 内容从底部到顶部填充的反向列表布局。

列表中的边缘按钮

对于 Material 3,您可以添加 EdgeButton,这是一个位于列表底部的贴边按钮。不过,请注意不要将此内容添加为 TransformingLazyColumn 中的项,而应使用 ScreenScaffold 中的 edgeButton slot。

使用 edgeButton slot 可确保按钮正确放置在屏幕底部,并在列表滚动时正常运行。

以下代码段展示了如何实现 EdgeButton

val columnState = rememberTransformingLazyColumnState()
ScreenScaffold(
    scrollState = columnState,
    edgeButton = {
        EdgeButton(
            onClick = { /* TODO */ },
            modifier = Modifier.scrollable(
                columnState,
                orientation = Orientation.Vertical,
                reverseDirection = true,
                // Apply overscroll to the EdgeButton for proper scrolling behavior.
                overscrollEffect = rememberOverscrollEffect(),
            )
        ) {
            Text("More")
        }
    }
) { contentPadding ->
    TransformingLazyColumn(
        contentPadding = contentPadding,
        state = columnState,
    ) {
        // ...
        // ...
    }
}

列表中的滑动显示

借助 SwipeToReveal 组件,您可以通过滑动来访问列表项的操作,例如 CardChip。滑动通常会从侧面显示一两个操作按钮(例如“删除”或“更多”)。

TransformingLazyColumn 中使用 SwipeToReveal 时,请遵循以下准则:

  • 在滚动时重置:当用户滚动列表时,将所有已滑动打开的项重置为覆盖状态。
  • 高度一致:将操作按钮的高度设置为与内部滑动项(无论是 Button 还是 Card)的高度一致,以确保外观一致。
  • 转换容器:将 transformedHeight 修饰符和 transformationSpec 应用于 SwipeToReveal 组件本身。
  • 请勿进行双重转换:请勿对内部滑动项(SwipeToReveal 容器内的卡片或按钮)应用 transformedHeighttransformation 修饰符。

列表中的自定义可组合项

TransformingLazyColumn 构建自定义界面组件时,请遵循以下最佳实践,以便您的可组合项在显示屏边缘附近平稳地缩放、淡入淡出和变形:

  • 公开 SurfaceTransformation:接受可选的 SurfaceTransformation 参数(默认为 null),与标准 Wear Compose Material 3 组件(如 CardButton)相匹配。这样一来,TransformingLazyColumn 中的来电者可以顺利通过 SurfaceTransformation(transformationSpec),同时让组件在列表之外正常工作。
  • 在调用代码中先应用 Modifier.transformedHeight:将自定义可组合项放置在 TransformingLazyColumn 中时,请在调用代码的修饰符链中将 Modifier.transformedHeight(this, transformationSpec) 作为第一个修饰符进行传递。虽然 SurfaceTransformation 会应用视觉缩放和淡入淡出效果,但 transformedHeight 对于告知列表布局在列表项缩小时重新计算其高度至关重要。
  • 按顺序应用转换层、调用方 modifier 和绘制器
    1. 容器转换层:使用 Modifier.graphicsLayerapplyContainerTransformation() 启动根容器的修饰器链,以便在缩放的倾斜坐标空间内绘制背景和内容。
    2. 调用方 modifier:先应用调用方传递的 modifier 参数(包括 Modifier.transformedHeight),然后再应用任何内部尺寸调整或内边距。
    3. 无转换时的形状剪辑:如果 transformationnull,请在绘制背景之前应用 Modifier.clip(shape)createContainerPainter() 返回的绘制器会自行剪裁为相应形状,但普通绘制器不会,因此如果没有此项,背景会以方形边角绘制在列表之外。
    4. 变形背景绘制器:使用 Modifier.drawBehind 和从 createContainerPainter() 创建的绘制器在容器层内绘制背景。
    5. 内容转换层:应用第二个 Modifier.graphicsLayer(包含 applyContentTransformation()),并裁剪到容器形状,以便内部内容在接近边框时更早淡出。

以下代码段展示了如何实现一个自定义 BoardingPassCard 可组合项,该可组合项按顺序应用这些转换:

@Composable
fun BoardingPassCard(
    flightNumber: String,
    origin: String,
    destination: String,
    gate: String,
    seat: String,
    departureTime: String,
    modifier: Modifier = Modifier,
    transformation: SurfaceTransformation? = null,
    shape: Shape = RoundedCornerShape(18.dp),
    statusBadge: @Composable () -> Unit = {}
) {
    // 1. Create morphing container painter
    val backgroundPainter = ColorPainter(MaterialTheme.colorScheme.surfaceContainer)
    val finalPainter = if (transformation != null) {
        remember(transformation, backgroundPainter, shape) {
            transformation.createContainerPainter(backgroundPainter, shape, border = null)
        }
    } else {
        backgroundPainter
    }

    Column(
        modifier = Modifier
            // 2a. Container layer: Scales, fades, and tilts the whole card surface
            .then(
                if (transformation != null) {
                    Modifier.graphicsLayer {
                        transformation.run { applyContainerTransformation() }
                    }
                } else Modifier
            )
            // 2b. Caller modifier: Includes Modifier.transformedHeight in a list
            .then(modifier)
            .fillMaxWidth()
            // 2c. Shape clip: Only needed without a transformation, because the
            // painter from createContainerPainter clips itself to the shape
            .then(if (transformation == null) Modifier.clip(shape) else Modifier)
            // 2d. Morphing background: Drawn inside the transformed container layer
            .drawBehind {
                with(finalPainter) {
                    draw(size)
                }
            }
            // 2e. Content layer: Fades content earlier and clips children to shape
            .then(
                if (transformation != null) {
                    Modifier.graphicsLayer {
                        this.shape = shape
                        this.clip = true
                        transformation.run { applyContentTransformation() }
                    }
                } else Modifier
            )
            .padding(horizontal = 14.dp, vertical = 10.dp)
    ) {
        // Card content goes here
    }
}
图 3. 一个自定义登机牌可组合函数,在滚动浏览列表时会发生转换。

然后,您可以在 TransformingLazyColumn 中使用 BoardingPassCard,方法是将 Modifier.transformedHeight 作为第一个修饰符传递,同时传递 SurfaceTransformation(transformationSpec)

@Composable
fun BoardingPassListSample(flights: List<FlightInfo>) {
    val listState = rememberTransformingLazyColumnState()
    val transformationSpec = rememberTransformationSpec()


    ScreenScaffold(scrollState = listState) { contentPadding ->
        TransformingLazyColumn(
            state = listState,
            contentPadding = contentPadding,
            modifier = Modifier.fillMaxSize()
        ) {
            items(flights.size) { index ->
                val flight = flights[index]
                BoardingPassCard(
                    flightNumber = flight.number,
                    origin = flight.origin,
                    destination = flight.destination,
                    gate = flight.gate,
                    seat = flight.seat,
                    departureTime = flight.time,
                    modifier = Modifier
                        .transformedHeight(this, transformationSpec)
                        .minimumVerticalContentPadding(
                            CardDefaults.minimumVerticalListContentPadding
                        ),
                    transformation = SurfaceTransformation(transformationSpec)
                )
            }
        }
    }
}