Các danh sách có Compose cho Wear OS


Danh sách cho phép người dùng chọn một mục trong một tập hợp các lựa chọn trên thiết bị Wear OS.

Nhiều thiết bị Wear OS sử dụng màn hình tròn, điều này khiến bạn khó xem được các mục trong danh sách xuất hiện ở gần đầu và cuối màn hình. Vì lý do này, Compose cho Wear OS thêm một phiên bản của lớp LazyColumn có tên là TransformingLazyColumn. Phiên bản này hỗ trợ các hiệu ứng chuyển tỉ lệ và biến đổi. Khi các mục di chuyển ra rìa, chúng sẽ nhỏ hơn và mờ dần.

Cách áp dụng hiệu ứng thu phóng và cuộn được đề xuất:

  1. Sử dụng Modifier.transformedHeight để cho phép Compose tính toán sự thay đổi chiều cao khi mục cuộn qua màn hình.
  2. Sử dụng transformation = SurfaceTransformation(transformationSpec) để áp dụng các hiệu ứng hình ảnh, bao gồm cả việc thu nhỏ nội dung của mục.
  3. Sử dụng TransformationSpec tuỳ chỉnh cho các thành phần không lấy transformation làm tham số, chẳng hạn như Text.

Ảnh động sau đây cho thấy cách một phần tử trong danh sách thay đổi kích thước và hình dạng khi tiến đến đầu và cuối màn hình:

Đoạn mã sau đây cho biết cách tạo danh sách bằng bố cục TransformingLazyColumn để tạo nội dung hiển thị đẹp mắt trên nhiều kích thước màn hình Wear OS.

Đoạn mã này cũng minh hoạ cách sử dụng đối tượng sửa đổi minimumVerticalContentPadding. Bạn nên đặt đối tượng sửa đổi này trên các mục trong danh sách để áp dụng khoảng đệm chính xác ở đầu và cuối danh sách.

Để hiện chỉ báo cuộn, hãy chia sẻ columnState giữa ScreenScaffoldTransformingLazyColumn:

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

Thêm hiệu ứng chụp nhanh và hất

Tính năng hít sẽ đảm bảo rằng khi người dùng hoàn tất cử chỉ cuộn hoặc hất, danh sách sẽ dừng lại với một mục được đặt chính xác tại một điểm cụ thể, thường là ở giữa màn hình. Trên màn hình tròn, nơi các mục được điều chỉnh tỷ lệ và biến đổi khi di chuyển ra xa tâm, tính năng căn chỉnh đặc biệt hữu ích để đảm bảo mục phù hợp nhất vẫn hiển thị đầy đủ và dễ đọc trong vùng xem tối ưu.

Để thêm hành vi hất và trượt nhanh, hãy đặt tham số flingBehavior thành TransformingLazyColumnDefaults.snapFlingBehavior(columnState). Đặt rotaryScrollableBehavior thành khớp bằng cách sử dụng RotaryScrollableDefaults.snapBehavior(columnState) để có trải nghiệm nhất quán khi sử dụng núm vặn hoặc viền vật lý.

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

Bố cục đảo ngược

Theo mặc định, danh sách có thể cuộn sẽ được cố định vào cạnh trên cùng. Nếu người dùng đã cuộn xuống cuối danh sách tiêu chuẩn và một mục mới được thêm vào cuối, thì danh sách sẽ duy trì chế độ xem của người dùng đối với mục hiện tại. Ví dụ: nếu người dùng đang xem mục 10 ở cuối màn hình và mục 11 được thêm vào, thì khung hiển thị vẫn tập trung vào mục 10 và mục 11 sẽ xuất hiện ngoài màn hình bên dưới khung hiển thị hiện tại.

Đối với các trường hợp sử dụng như ứng dụng nhắn tin hoặc nhật ký trực tiếp, bạn thường không muốn hành vi này. Khi có các mục mới, người dùng thường muốn xem ngay nội dung mới nhất nếu họ đã ở cuối danh sách. Nếu nhiều mục đến cùng lúc, danh sách sẽ bỏ qua để hiển thị mục mới nhất ở dưới cùng (nghĩa là một số mục trung gian có thể không hiển thị trừ phi người dùng cuộn lên).

Để hỗ trợ các trường hợp sử dụng này, TransformingLazyColumn cho phép bạn đảo ngược bố cục bằng cách đặt reverseLayout = true. Thao tác này sẽ thay đổi điểm neo của danh sách từ cạnh trên thành cạnh dưới.

Để thuận tiện, việc đặt reverseLayout = true cũng đảo ngược thứ tự hiển thị của các mục và hướng của cử chỉ cuộn:

  • Các mục được tạo từ dưới lên, tức là chỉ mục 0 xuất hiện ở cuối màn hình.
  • Khi bạn di chuyển lên, các mục có chỉ mục cao hơn sẽ xuất hiện.

Để thêm hành vi hất và trượt cùng với bố cục đảo ngược, bạn có thể kết hợp flingBehaviorrotaryScrollableBehavior như minh hoạ trong đoạn mã sau:

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

Các hình ảnh sau đây cho thấy sự khác biệt giữa danh sách thông thường và danh sách đảo ngược:

Một TransformingLazyColumn có bố cục bình thường, hiển thị Mục 1 ở trên cùng và các mục theo thứ tự tăng dần.
Hình 1. Một bố cục danh sách tiêu chuẩn, trong đó nội dung sẽ lấp đầy từ trên xuống dưới.
Một TransformingLazyColumn có bố cục đảo ngược, cho thấy Mục 1 ở dưới cùng và các mục theo thứ tự giảm dần về phía trên cùng.
Hình 2. Bố cục danh sách đảo ngược, trong đó nội dung lấp đầy từ dưới lên trên.

Các nút ở cạnh trên danh sách

Đối với Material 3, bạn có thể thêm một EdgeButton. Đây là nút ôm viền ở cuối danh sách. Tuy nhiên, hãy cẩn thận để không thêm phần này dưới dạng một mục trong TransformingLazyColumn mà thay vào đó, hãy dùng khe edgeButton trong ScreenScaffold.

Việc sử dụng vùng edgeButton đảm bảo rằng nút được đặt đúng vị trí ở cuối màn hình và hoạt động phù hợp khi danh sách được cuộn.

Đoạn mã sau đây cho biết cách triển khai 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,
    ) {
        // ...
        // ...
    }
}

Thao tác vuốt để hiển thị trong danh sách

Thành phần SwipeToReveal cho phép bạn truy cập vào các thao tác cho một mục trong danh sách, chẳng hạn như Card hoặc Chip, bằng cách vuốt. Thao tác vuốt thường sẽ làm lộ ra một hoặc hai nút thao tác (chẳng hạn như "Xoá" hoặc "Tuỳ chọn khác") ở bên cạnh.

Khi sử dụng SwipeToReveal trong TransformingLazyColumn, hãy tuân thủ các nguyên tắc sau:

  • Đặt lại khi cuộn: Khi người dùng cuộn danh sách, hãy đặt lại mọi mục đã mở bằng thao tác vuốt về trạng thái bị che.
  • Chiều cao nhất quán: Đặt chiều cao của nút hành động sao cho khớp với mục được vuốt bên trong (cho dù đó là Button hay Card) để đảm bảo giao diện nhất quán.
  • Biến đổi vùng chứa: Áp dụng đối tượng sửa đổi transformedHeighttransformationSpec cho chính thành phần SwipeToReveal.
  • Không biến đổi gấp đôi: Đừng áp dụng phương thức sửa đổi transformedHeight hoặc transformation cho mục được vuốt bên trong (thẻ hoặc nút bên trong vùng chứa SwipeToReveal).

Thành phần kết hợp tuỳ chỉnh trong danh sách

Khi tạo các thành phần tuỳ chỉnh cho nền tảng TransformingLazyColumn, hãy làm theo các phương pháp hay nhất sau đây để thành phần kết hợp của bạn có thể mở rộng, làm mờ và biến đổi một cách mượt mà gần các cạnh của màn hình:

  • Hiển thị SurfaceTransformation: Chấp nhận một tham số SurfaceTransformation không bắt buộc (mặc định là null), khớp với các thành phần Material 3 Compose tiêu chuẩn của Wear như CardButton. Thao tác này cho phép người gọi trong một TransformingLazyColumn truyền SurfaceTransformation(transformationSpec) trong khi cho phép thành phần hoạt động bình thường bên ngoài danh sách.
  • Áp dụng Modifier.transformedHeight trước trong mã gọi: Khi đặt thành phần kết hợp tuỳ chỉnh của bạn trong một TransformingLazyColumn, hãy truyền Modifier.transformedHeight(this, transformationSpec) làm đối tượng sửa đổi đầu tiên trong chuỗi đối tượng sửa đổi của mã gọi. Mặc dù SurfaceTransformation áp dụng hiệu ứng thu nhỏ và làm mờ hình ảnh, nhưng transformedHeight lại rất quan trọng trong việc yêu cầu bố cục danh sách tính toán lại chiều cao của mục khi mục đó thu nhỏ.
  • Áp dụng các lớp biến đổi, modifier người gọi và trình vẽ theo thứ tự:
    1. Lớp biến đổi vùng chứa: Bắt đầu chuỗi đối tượng sửa đổi của vùng chứa gốc bằng Modifier.graphicsLayerapplyContainerTransformation(), để cả nền và nội dung đều được vẽ trong không gian toạ độ nghiêng, được chia tỷ lệ.
    2. Đối tượng gọi modifier: Áp dụng tham số modifier do đối tượng gọi truyền (bao gồm cả Modifier.transformedHeight) tiếp theo, trước khi có bất kỳ kích thước hoặc khoảng đệm nội bộ nào.
    3. Cắt theo hình dạng khi không có phép biến đổi: Nếu transformationnull, hãy áp dụng Modifier.clip(shape) trước khi vẽ nền. Đối tượng vẽ do createContainerPainter() trả về sẽ tự cắt theo hình dạng, nhưng đối tượng vẽ thông thường thì không. Vì vậy, nếu không có đối tượng vẽ này, nền sẽ được vẽ bằng các góc vuông bên ngoài danh sách.
    4. Painter nền biến đổi: Vẽ nền bên trong lớp vùng chứa bằng Modifier.drawBehind và một painter được tạo từ createContainerPainter().
    5. Lớp biến đổi nội dung: Áp dụng Modifier.graphicsLayer thứ hai bằng applyContentTransformation() và cắt vào hình dạng vùng chứa để nội dung bên trong mờ dần sớm hơn khi nội dung đó tiếp cận viền.

Đoạn mã sau đây cho biết cách triển khai một thành phần kết hợp BoardingPassCard tuỳ chỉnh áp dụng các phép biến đổi này theo thứ tự:

@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
    }
}
Hình 3. Một thành phần kết hợp thẻ lên máy bay tuỳ chỉnh sẽ biến đổi khi cuộn qua danh sách.

Sau đó, bạn có thể dùng BoardingPassCard bên trong TransformingLazyColumn bằng cách truyền Modifier.transformedHeight làm đối tượng sửa đổi đầu tiên, cùng với 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)
                )
            }
        }
    }
}