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)。使用 RotaryScrollableDefaults.snapBehavior(columnState)rotaryScrollableBehavior 設為相符,以便在使用實體錶冠或錶圈時獲得一致的體驗。

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,頂端顯示「項目 1」,項目則依遞增順序排列。
圖 1. 內容由上而下填滿的標準清單版面配置。
具有反向版面配置的 TransformingLazyColumn,底部顯示「項目 1」,項目由下往上依遞減順序排列。
圖 2. 內容從底部填滿至頂部的反向清單版面配置。

清單中的邊緣按鈕

如果是 Material 3,您可以新增 EdgeButton,這是位於清單底部的邊緣按鈕。不過,請注意不要將這個項目新增至 TransformingLazyColumn 中,而是使用 ScreenScaffold 中的 edgeButton 插槽。

使用 edgeButton 插槽可確保按鈕正確放置在畫面底部,並在捲動清單時正常運作。

下列程式碼片段說明如何實作 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 時,請遵循下列準則:

  • 捲動時重設:使用者捲動清單時,將所有已滑開的項目重設為遮蓋狀態。
  • 高度一致:將動作按鈕高度設為與內部滑動項目 (ButtonCard) 相同,確保外觀一致。
  • 轉換容器:將 transformedHeight 修飾符和 transformationSpec 套用至 SwipeToReveal 元件本身。
  • 請勿重複轉換:請勿對內部滑動項目 (SwipeToReveal 容器內的資訊卡或按鈕) 套用 transformedHeighttransformation 修飾符。

清單中的自訂可組合函式

TransformingLazyColumn 建構自訂介面元件時,請遵循下列最佳做法,確保可組合函式在螢幕邊緣附近順暢縮放、淡出及變形:

  • 公開 SurfaceTransformation:接受選用的 SurfaceTransformation 參數 (預設為 null),與標準 Wear OS 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)
                )
            }
        }
    }
}