รายการด้วย Compose สำหรับ Wear OS


รายการช่วยให้ผู้ใช้เลือกรายการจากชุดตัวเลือกในอุปกรณ์ Wear OS ได้

อุปกรณ์ Wear OS หลายรุ่นใช้หน้าจอทรงกลม ซึ่งทำให้ดูรายการที่ปรากฏใกล้กับด้านบนและด้านล่างของหน้าจอได้ยากขึ้น ด้วยเหตุนี้ Compose สำหรับ Wear OS จึงมีLazyColumnเวอร์ชันหนึ่งที่เรียกว่า TransformingLazyColumn ซึ่งรองรับการปรับขนาดและภาพเคลื่อนไหวแบบมอร์ฟ เมื่อรายการเลื่อนไปที่ขอบ รายการจะเล็กลงและจางหายไป

วิธีใช้เอฟเฟกต์การปรับขนาดและการเลื่อนที่แนะนำ

  1. ใช้ Modifier.transformedHeight เพื่อให้ Compose คำนวณการเปลี่ยนแปลงความสูงขณะที่รายการเลื่อนผ่าน หน้าจอ
  2. ใช้ transformation = SurfaceTransformation(transformationSpec) เพื่อใช้ เอฟเฟกต์ภาพ ซึ่งรวมถึงการลดขนาดเนื้อหาของไอเทม
  3. ใช้ TransformationSpec ที่กำหนดเองสำหรับคอมโพเนนต์ที่ไม่ใช้ transformation เป็นพารามิเตอร์ เช่น Text

ภาพเคลื่อนไหวต่อไปนี้แสดงวิธีที่องค์ประกอบรายการปรับขนาดและเปลี่ยนรูปร่าง เมื่อเข้าใกล้ด้านบนและด้านล่างของหน้าจอ

ข้อมูลโค้ดต่อไปนี้แสดงวิธีสร้างรายการโดยใช้เลย์เอาต์ TransformingLazyColumn เพื่อสร้างเนื้อหาที่ ดูดีในขนาดหน้าจอ Wear OS ที่หลากหลาย

ข้อมูลโค้ดยังแสดงให้เห็นการใช้ตัวแก้ไข minimumVerticalContentPadding ซึ่งคุณควรตั้งค่าในรายการ เพื่อใช้ระยะห่างที่ถูกต้องที่ด้านบนและด้านล่างของรายการ

หากต้องการแสดงตัวบ่งชี้การเลื่อน ให้แชร์ columnState ระหว่าง ScreenScaffold กับ TransformingLazyColumn โดยทำดังนี้

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 จะปรากฏที่ด้านล่างของหน้าจอ
  • การเลื่อนขึ้นจะแสดงรายการที่มีดัชนีสูงกว่า

หากต้องการเพิ่มลักษณะการทำงานแบบดีดและปัดพร้อมกับเลย์เอาต์ย้อนกลับ คุณสามารถรวม flingBehavior และ rotaryScrollableBehavior ดังที่แสดงใน ข้อมูลโค้ดต่อไปนี้

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 แต่ให้ใช้ช่อง edgeButton ใน ScreenScaffold แทน

การใช้ช่อง 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 ช่วยให้คุณเข้าถึงการดำเนินการสำหรับรายการในลิสต์ เช่น Card หรือ Chip ได้โดยการปัด โดยปกติการปัดจะแสดงปุ่มการดำเนินการ 1 หรือ 2 ปุ่ม (เช่น "ลบ" หรือ "เพิ่มเติม") จากด้านข้าง

เมื่อใช้ SwipeToReveal ภายใน TransformingLazyColumn ให้ทำตามหลักเกณฑ์ต่อไปนี้

  • รีเซ็ตเมื่อเลื่อน: เมื่อผู้ใช้เลื่อนรายการ ให้รีเซ็ตรายการที่ปัดเปิด ไปแล้วให้กลับสู่สถานะที่ปิด
  • ความสูงที่สอดคล้องกัน: ตั้งค่าความสูงของปุ่มการดำเนินการให้ตรงกับรายการที่ปัดด้านใน (ไม่ว่าจะเป็น Button หรือ Card) เพื่อให้ดูสอดคล้องกัน
  • เปลี่ยนรูปแบบคอนเทนเนอร์: ใช้ตัวแก้ไข transformedHeight และ transformationSpec กับคอมโพเนนต์ SwipeToReveal เอง
  • อย่าเปลี่ยนรูปแบบซ้ำ: อย่าใช้ตัวแก้ไข transformedHeight หรือ transformation กับรายการที่ปัดด้านใน (การ์ดหรือปุ่ม ภายในคอนเทนเนอร์ SwipeToReveal)

Composable ที่กำหนดเองในรายการ

เมื่อสร้างคอมโพเนนต์ Surface ที่กำหนดเองสำหรับ TransformingLazyColumn ให้ทำตาม แนวทางปฏิบัติแนะนำเหล่านี้เพื่อให้คอมโพเนนต์ที่ประกอบได้ปรับขนาด จาง และเปลี่ยนรูปร่างได้อย่างราบรื่นใกล้กับ ขอบจอแสดงผล

  • Expose SurfaceTransformation: ยอมรับพารามิเตอร์ SurfaceTransformation ที่ไม่บังคับ (ค่าเริ่มต้นคือ null) ซึ่งตรงกับ คอมโพเนนต์ Material 3 ของ Wear Compose มาตรฐาน เช่น Card และ Button ซึ่งจะช่วยให้ผู้โทรผ่านTransformingLazyColumnSurfaceTransformation(transformationSpec)ได้ ขณะที่คอมโพเนนต์ทำงาน ตามปกติเมื่ออยู่นอกรายการ
  • ใช้ Modifier.transformedHeight เป็นอันดับแรกในโค้ดการเรียก: เมื่อวาง Composable ที่กำหนดเองใน TransformingLazyColumn ให้ส่ง Modifier.transformedHeight(this, transformationSpec) เป็นตัวแก้ไขแรก ในเชนตัวแก้ไขของโค้ดการเรียก ขณะที่ SurfaceTransformation ใช้ เอฟเฟกต์การปรับขนาดและการจางภาพ transformedHeight ก็มีความสำคัญต่อ การบอกเลย์เอาต์รายการให้คำนวณความสูงของรายการใหม่เมื่อรายการเล็กลง
  • ใช้เลเยอร์การเปลี่ยนรูปแบบ, ผู้โทร modifier และจิตรกรตามลำดับ
    1. เลเยอร์การเปลี่ยนรูปแบบคอนเทนเนอร์: เริ่มห่วงโซ่ตัวแก้ไขของคอนเทนเนอร์รูทด้วย Modifier.graphicsLayer และ applyContainerTransformation() เพื่อให้ทั้งพื้นหลังและ เนื้อหาแสดงภายในพื้นที่พิกัดที่ปรับขนาดและเอียง
    2. ผู้โทร modifier: ใช้พารามิเตอร์ modifier ที่ส่งโดยผู้โทร (ซึ่งรวมถึง Modifier.transformedHeight) ถัดไป ก่อนการปรับขนาดหรือการเว้นวรรคภายใน
    3. ตัดรูปร่างเมื่อไม่มีการเปลี่ยนรูปแบบ: หาก transformation เป็น null ให้ใช้ Modifier.clip(shape) ก่อนวาดพื้นหลัง createContainerPainter() ที่ส่งคืนโดย Painter จะตัดตัวเองให้พอดีกับรูปร่าง แต่ Painter ธรรมดาจะไม่ทำเช่นนั้น ดังนั้นหากไม่มีการตั้งค่านี้ ระบบจะวาดพื้นหลังเป็นมุมสี่เหลี่ยมจัตุรัสนอกรายการ
    4. เครื่องมือวาดภาพพื้นหลังที่เปลี่ยนรูป: วาดพื้นหลังภายในเลเยอร์คอนเทนเนอร์โดยใช้ Modifier.drawBehind และเครื่องมือวาดภาพที่สร้างจาก createContainerPainter()
    5. เลเยอร์การเปลี่ยนรูปแบบเนื้อหา: ใช้Modifier.graphicsLayerที่ 2 กับ applyContentTransformation() และคลิป กับรูปร่างคอนเทนเนอร์เพื่อให้เนื้อหาด้านในจางหายไปก่อนหน้านี้เมื่อเข้าใกล้ขอบ

ข้อมูลโค้ดต่อไปนี้แสดงวิธีใช้ Composable ของ 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 บอร์ดดิ้งพาสที่กำหนดเองซึ่งเป็น Composable จะเปลี่ยนรูปแบบเมื่อเลื่อนผ่านรายการ

จากนั้นคุณจะใช้ BoardingPassCard ภายใน TransformingLazyColumn ได้โดยส่ง 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)
                )
            }
        }
    }
}