Wear OS 向け Compose でのリスト


リストを使用すると、Wear OS デバイス上でユーザーが選択肢の中からアイテムを選択できるようになります。

多くの Wear OS デバイスは円形の画面であるため、画面の上部や下部付近に表示されるリストアイテムが見づらくなっています。このため、Wear OS 向け Compose には、スケーリングとモーフィングのアニメーションをサポートする TransformingLazyColumn という LazyColumn クラスのバージョンが含まれています。アイテムが端に移動すると、アイテムは小さくなり、フェードアウトします。

推奨される拡大縮小とスクロールの効果を適用するには:

  1. Compose でアイテムが画面をスクロールする際の高さの変化を計算できるようにするには、Modifier.transformedHeight を使用します。
  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。Item 1 が一番上に表示され、アイテムが昇順で表示されている。
図 1. コンテンツが上から下に埋められる標準のリスト レイアウト。
レイアウトが反転した TransformingLazyColumn。一番下に Item 1 が表示され、上に向かって降順でアイテムが表示されている。
図 2. コンテンツが下から上に埋められる逆順のリスト レイアウト。

リストの端にあるボタン

マテリアル 3 では、リストの下部にエッジに沿ったボタンである EdgeButton を追加できます。ただし、これを TransformingLazyColumn 内のアイテムとして追加するのではなく、ScreenScaffoldedgeButton スロットを使用するようにしてください。

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

SwipeToReveal コンポーネントを使用すると、スワイプしてリストアイテムのアクション(CardChip など)にアクセスできます。通常、スワイプすると、横から 1 つまたは 2 つのアクション ボタン([削除] や [その他] など)が表示されます。

TransformingLazyColumn 内で SwipeToReveal を使用する場合は、次のガイドラインに沿ってください。

  • スクロール時にリセット: ユーザーがリストをスクロールしたときに、スワイプして開いたアイテムを覆われた状態にリセットします。
  • 高さの一貫性: 操作ボタンの高さを、内側のスワイプ アイテム(Button または Card)に合わせて設定し、一貫した外観を確保します。
  • コンテナを変換する: transformedHeight 修飾子と transformationSpecSwipeToReveal コンポーネント自体に適用します。
  • 二重変換しない: 内側のスワイプ アイテム(SwipeToReveal コンテナ内のカードまたはボタン)に transformedHeight 修飾子または transformation 修飾子を適用しないでください。

リスト内のカスタム コンポーザブル

TransformingLazyColumn 用のカスタム サーフェス コンポーネントを構築する際は、次のベスト プラクティスに沿って、コンポーザブルがディスプレイの端付近でスムーズにスケーリング、フェード、モーフィングされるようにします。

  • SurfaceTransformation を公開: 標準の Wear Compose マテリアル 3 コンポーネント(CardButton など)に合わせて、オプションの SurfaceTransformation パラメータ(デフォルトは null)を受け入れます。これにより、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.drawBehindcreateContainerPainter() から作成されたペインタを使用して、コンテナレイヤの内部に背景を描画します。
    5. コンテンツ変換レイヤ: applyContentTransformation() とクリップを使用して、コンテナの形状に 2 つ目の Modifier.graphicsLayer を適用し、内側のコンテンツがベゼルに近づくにつれて早くフェードアウトするようにします。

次のスニペットは、これらの変換を順番に適用するカスタム 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. リストをスクロールすると変換されるカスタム搭乗券コンポーザブル。

SurfaceTransformation(transformationSpec) とともに Modifier.transformedHeight を最初の修飾子として渡すことで、TransformingLazyColumn 内で BoardingPassCard を使用できます。

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