รายการและตารางกริดแบบเลซี

แอปจำนวนมากจำเป็นต้องแสดงคอลเล็กชันของรายการต่างๆ เอกสารนี้อธิบายวิธีที่คุณสามารถทำสิ่งนี้ได้อย่างมีประสิทธิภาพใน Jetpack Compose

หากทราบว่ากรณีการใช้งานของคุณไม่จำเป็นต้องเลื่อน คุณอาจต้องการใช้ Column หรือ Row แบบง่าย (ขึ้นอยู่กับทิศทาง) และแสดงเนื้อหาของแต่ละรายการโดยทำซ้ำในรายการด้วยวิธีต่อไปนี้

@Composable
fun MessageList(messages: List<Message>) {
    Column {
        messages.forEach { message ->
            MessageRow(message)
        }
    }
}

เราสามารถทำให้ Column เลื่อนได้โดยใช้ตัวแก้ไข verticalScroll()

รายการแบบ Lazy

หากต้องการแสดงรายการจำนวนมาก (หรือรายการที่มีความยาวไม่แน่นอน) การใช้เลย์เอาต์อย่าง Column อาจทำให้เกิดปัญหาด้านประสิทธิภาพ เนื่องจากระบบจะสร้างและจัดวางรายการทั้งหมดไม่ว่าจะมองเห็นหรือไม่ก็ตาม

Compose มีชุดคอมโพเนนต์ที่เขียนและจัดวางเฉพาะรายการที่มองเห็นได้ในวิวพอร์ตของคอมโพเนนต์เท่านั้น ซึ่งได้แก่ LazyColumn และ LazyRow

ตามชื่อที่บอกไว้ ความแตกต่างระหว่าง LazyColumn กับ LazyRow คือการวางแนวที่ใช้จัดวางรายการและการเลื่อน LazyColumn จะสร้างรายการแบบเลื่อนในแนวตั้ง และ LazyRow จะสร้างรายการแบบเลื่อนในแนวนอน

คอมโพเนนต์ Lazy แตกต่างจากเลย์เอาต์ส่วนใหญ่ใน Compose คอมโพเนนต์ Lazy จะมีบล็อก LazyListScope.() แทนที่จะยอมรับพารามิเตอร์บล็อกเนื้อหา @Composable ซึ่งช่วยให้แอปส่ง Composable ได้โดยตรง บล็อก LazyListScope นี้มี DSL ที่ช่วยให้แอปอธิบายเนื้อหาของรายการได้ จากนั้นคอมโพเนนต์ Lazy จะมีหน้าที่เพิ่มเนื้อหาของแต่ละรายการตามที่เลย์เอาต์และตำแหน่งการเลื่อนกำหนด

DSL LazyListScope รายการ

DSL ของ LazyListScope มีฟังก์ชันมากมายสำหรับอธิบายรายการในเลย์เอาต์ โดยพื้นฐานที่สุดแล้ว item() จะเพิ่มรายการเดียว และ items(Int) จะเพิ่มหลายรายการ ดังนี้

LazyColumn {
    // Add a single item
    item {
        Text(text = "First item")
    }

    // Add 5 items
    items(5) { index ->
        Text(text = "Item: $index")
    }

    // Add another single item
    item {
        Text(text = "Last item")
    }
}

นอกจากนี้ยังมีฟังก์ชันส่วนขยายจำนวนมากที่ช่วยให้คุณเพิ่มคอลเล็กชันของรายการต่างๆ ได้ เช่น List ส่วนขยายเหล่านี้ช่วยให้เราย้ายข้อมูลตัวอย่าง Column จากด้านบนได้อย่างง่ายดาย ดังนี้

/**
 * import androidx.compose.foundation.lazy.items
 */
LazyColumn {
    items(messages) { message ->
        MessageRow(message)
    }
}

นอกจากนี้ยังมีตัวแปรของฟังก์ชันส่วนขยาย items() ที่เรียกว่า itemsIndexed() ซึ่งให้ดัชนี โปรดดูรายละเอียดเพิ่มเติมในข้อมูลอ้างอิงของ LazyListScope

ตารางกริดแบบ Lazy

ฟังก์ชันที่ประกอบกันได้ LazyVerticalGrid และ LazyHorizontalGrid รองรับการแสดงรายการในตารางกริด Lazy vertical grid จะแสดงรายการในคอนเทนเนอร์ที่เลื่อนได้ในแนวตั้ง โดยกระจายไปตามคอลัมน์ต่างๆ ส่วน Lazy horizontal grid จะมีลักษณะการทำงานเหมือนกันในแกนนอน

ตารางกริดมีความสามารถ API ที่มีประสิทธิภาพเหมือนกับรายการ และยังใช้ DSL ที่คล้ายกันมากอย่าง LazyGridScope.() เพื่ออธิบายเนื้อหาด้วย

ภาพหน้าจอโทรศัพท์ที่แสดงตารางกริดรูปภาพ

พารามิเตอร์ columns ใน LazyVerticalGrid และพารามิเตอร์ rows ใน LazyHorizontalGrid จะควบคุมวิธีจัดรูปแบบเซลล์เป็นคอลัมน์หรือแถว ตัวอย่างต่อไปนี้แสดงรายการในตารางกริด โดยใช้ GridCells.Adaptive เพื่อตั้งค่าให้แต่ละคอลัมน์มีความกว้างอย่างน้อย 128.dp

LazyVerticalGrid(
    columns = GridCells.Adaptive(minSize = 128.dp)
) {
    items(photos) { photo ->
        PhotoItem(photo)
    }
}

LazyVerticalGrid ช่วยให้คุณระบุความกว้างของรายการได้ จากนั้นตารางกริดจะปรับให้พอดีกับคอลัมน์ให้ได้มากที่สุด ความกว้างที่เหลือจะกระจายไปยังคอลัมน์ต่างๆ อย่างเท่าเทียมกันหลังจากคำนวณจำนวนคอลัมน์แล้ว การปรับขนาดแบบปรับเปลี่ยนได้นี้มีประโยชน์อย่างยิ่งสำหรับการแสดงชุดรายการในหน้าจอขนาดต่างๆ

หากทราบจำนวนคอลัมน์ที่แน่นอนที่จะใช้ คุณสามารถระบุอินสแตนซ์ของ GridCells.Fixed ที่มีจำนวนคอลัมน์ที่จำเป็นแทนได้

หากการออกแบบของคุณกำหนดให้เฉพาะบางรายการมีขนาดที่ไม่เป็นมาตรฐาน คุณสามารถใช้การรองรับตารางกริดเพื่อระบุการขยายคอลัมน์ที่กำหนดเองสำหรับรายการต่างๆ ได้ ระบุการขยายคอลัมน์ด้วยพารามิเตอร์ span ของเมธอด LazyGridScope DSL item และ items maxLineSpan ซึ่งเป็นค่าหนึ่งของขอบเขตการขยายจะมีประโยชน์อย่างยิ่งเมื่อคุณใช้การปรับขนาดที่ปรับเปลี่ยนตามอุปกรณ์ เนื่องจากจำนวนคอลัมน์ไม่คงที่ ตัวอย่างนี้แสดงวิธีระบุการขยายแถวแบบเต็ม

LazyVerticalGrid(
    columns = GridCells.Adaptive(minSize = 30.dp)
) {
    item(span = {
        // LazyGridItemSpanScope:
        // maxLineSpan
        GridItemSpan(maxLineSpan)
    }) {
        CategoryCard("Fruits")
    }
    // ...
}

ตารางกริดแบบสลับฟันปลาที่โหลดแบบ Lazy

LazyVerticalStaggeredGrid และ LazyHorizontalStaggeredGrid เป็น Composable ที่ช่วยให้คุณสร้างตารางกริดแบบสลับฟันปลาของรายการที่โหลดแบบ Lazy Loading ได้ ตารางกริดแบบสลับแนวตั้งแบบ Lazy จะแสดงรายการในคอนเทนเนอร์ที่เลื่อนได้ในแนวตั้งซึ่งครอบคลุมหลายคอลัมน์และอนุญาตให้แต่ละรายการมีความสูงต่างกันได้ ตารางกริดแนวนอนแบบ Lazy มีลักษณะการทำงานเหมือนกันในแกนนอนที่มีรายการซึ่งมีความกว้างต่างกัน

ข้อมูลโค้ดต่อไปนี้เป็นตัวอย่างพื้นฐานของการใช้ LazyVerticalStaggeredGrid ที่มีความกว้าง 200.dp ต่อรายการ

LazyVerticalStaggeredGrid(
    columns = StaggeredGridCells.Adaptive(200.dp),
    verticalItemSpacing = 4.dp,
    horizontalArrangement = Arrangement.spacedBy(4.dp),
    content = {
        items(randomSizedPhotos) { photo ->
            AsyncImage(
                model = photo,
                contentScale = ContentScale.Crop,
                contentDescription = null,
                modifier = Modifier
                    .fillMaxWidth()
                    .wrapContentHeight()
            )
        }
    },
    modifier = Modifier.fillMaxSize()
)

รูปที่ 1 ตัวอย่างตารางกริดแนวตั้งแบบ Lazy Staggered

หากต้องการกำหนดจำนวนคอลัมน์ที่แน่นอน คุณสามารถใช้ StaggeredGridCells.Fixed(columns) แทน StaggeredGridCells.Adaptive ได้ ซึ่งจะแบ่งความกว้างที่ใช้ได้ตามจำนวนคอลัมน์ (หรือแถวสำหรับตารางกริดแนวนอน) และทำให้แต่ละรายการใช้ความกว้าง (หรือความสูงสำหรับตารางกริดแนวนอน) ดังนี้

LazyVerticalStaggeredGrid(
    columns = StaggeredGridCells.Fixed(3),
    verticalItemSpacing = 4.dp,
    horizontalArrangement = Arrangement.spacedBy(4.dp),
    content = {
        items(randomSizedPhotos) { photo ->
            AsyncImage(
                model = photo,
                contentScale = ContentScale.Crop,
                contentDescription = null,
                modifier = Modifier
                    .fillMaxWidth()
                    .wrapContentHeight()
            )
        }
    },
    modifier = Modifier.fillMaxSize()
)
ตารางกริดแบบ Lazy Staggered ของรูปภาพใน Compose
รูปที่ 2 ตัวอย่างของตารางกริดแนวตั้งแบบสลับฟันปลาที่โหลดแบบ Lazy Loading โดยมีคอลัมน์คงที่

ระยะห่างจากขอบเนื้อหา

บางครั้งคุณจะต้องเพิ่มระยะห่างจากขอบรอบๆ ขอบของเนื้อหา คอมโพเนนต์ Lazy ช่วยให้คุณส่ง PaddingValues บางรายการไปยังพารามิเตอร์ contentPadding เพื่อรองรับการดำเนินการนี้ได้

LazyColumn(
    contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
) {
    // ...
}

ในตัวอย่างนี้ เราเพิ่มระยะห่างจากขอบ 16.dp ไปยังขอบแนวนอน (ซ้ายและขวา) แล้วเพิ่ม 8.dp ไปยังด้านบนและด้านล่างของเนื้อหา

โปรดทราบว่าระบบจะใช้ระยะห่างจากขอบนี้กับเนื้อหา ไม่ใช่กับ LazyColumn ในตัวอย่างด้านบน รายการแรกจะเพิ่มระยะห่างจากขอบ 8.dp ที่ด้านบน รายการสุดท้ายจะเพิ่มระยะห่างจากขอบ 8.dp ที่ด้านล่าง และรายการทั้งหมดจะมีระยะห่างจากขอบ 16.dp ทางด้านซ้ายและขวา

อีกตัวอย่างหนึ่งคือ คุณสามารถส่ง PaddingValues ของ Scaffold ไปยัง contentPadding ของ LazyColumn ได้ ดูคำแนะนำเกี่ยวกับการแสดงผลแบบไร้ขอบ

ระยะห่างของเนื้อหา

หากต้องการเพิ่มระยะห่างระหว่างรายการต่างๆ คุณสามารถใช้ Arrangement.spacedBy() ตัวอย่างด้านล่างจะเพิ่มระยะห่าง 4.dp ระหว่างแต่ละรายการ

LazyColumn(
    verticalArrangement = Arrangement.spacedBy(4.dp),
) {
    // ...
}

ในทำนองเดียวกัน สำหรับ LazyRow ให้ทำดังนี้

LazyRow(
    horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
    // ...
}

อย่างไรก็ตาม ตารางกริดจะรองรับทั้งการจัดเรียงแนวตั้งและแนวนอน ดังนี้

LazyVerticalGrid(
    columns = GridCells.Fixed(2),
    verticalArrangement = Arrangement.spacedBy(16.dp),
    horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
    items(photos) { item ->
        PhotoItem(item)
    }
}

คีย์รายการ

โดยค่าเริ่มต้น สถานะของแต่ละรายการจะเชื่อมโยงกับตำแหน่งของรายการในลิสต์หรือตารางกริด อย่างไรก็ตาม วิธีนี้อาจทำให้เกิดปัญหาหากชุดข้อมูลมีการเปลี่ยนแปลง เนื่องจากรายการที่เปลี่ยนตำแหน่งจะสูญเสียสถานะที่จดจำไว้ทั้งหมด หากคุณนึกภาพสถานการณ์ของ LazyRow ภายใน LazyColumn หากแถวเปลี่ยนตำแหน่งรายการ ผู้ใช้จะสูญเสียตำแหน่งการเลื่อนภายในแถว

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

LazyColumn {
    items(
        items = messages,
        key = { message ->
            // Return a stable + unique key for the item
            message.id
        }
    ) { message ->
        MessageRow(message)
    }
}

การระบุคีย์จะช่วยให้ Compose จัดการการเรียงลำดับใหม่ได้อย่างถูกต้อง ตัวอย่างเช่น หากรายการมีสถานะที่จดจำไว้ การตั้งค่าคีย์จะช่วยให้ Compose ย้ายสถานะนี้ไปพร้อมกับรายการเมื่อตำแหน่งเปลี่ยนไป

LazyColumn {
    items(books, key = { it.id }) {
        val rememberedValue = remember {
            Random.nextInt()
        }
    }
}

อย่างไรก็ตาม มีข้อจำกัด 1 ข้อเกี่ยวกับประเภทที่คุณใช้เป็นคีย์รายการได้ ประเภทของคีย์ต้องรองรับโดย Bundle ซึ่งเป็นกลไกของ Android ในการรักษาสถานะเมื่อมีการสร้างกิจกรรมขึ้นมาใหม่ Bundle รองรับประเภทต่างๆ เช่น ข้อมูลพื้นฐาน, enum หรือ Parcelable

LazyColumn {
    items(books, key = {
        // primitives, enums, Parcelable, etc.
    }) {
        // ...
    }
}

Bundle ต้องรองรับคีย์เพื่อให้สามารถกู้คืน rememberSaveable ภายใน Composable ของรายการได้เมื่อมีการสร้างกิจกรรมขึ้นมาใหม่ หรือแม้แต่เมื่อคุณเลื่อนออกจากรายการนี้แล้วเลื่อนกลับมา

LazyColumn {
    items(books, key = { it.id }) {
        val rememberedValue = rememberSaveable {
            Random.nextInt()
        }
    }
}

ภาพเคลื่อนไหวของรายการ

หากเคยใช้วิดเจ็ต RecyclerView คุณจะทราบว่าวิดเจ็ตนี้ทำให้การเปลี่ยนแปลงรายการเป็นภาพเคลื่อนไหวโดยอัตโนมัติ เลย์เอาต์แบบ Lazy มีฟังก์ชันการทำงานเดียวกันสำหรับการจัดเรียงรายการใหม่ API นี้ใช้งานง่าย เพียงแค่ตั้งค่าตัวแก้ไข animateItem ให้กับเนื้อหาของรายการ ดังนี้

LazyColumn {
    // It is important to provide a key to each item to ensure animateItem() works as expected.
    items(books, key = { it.id }) {
        Row(Modifier.animateItem()) {
            // ...
        }
    }
}

คุณยังระบุข้อกำหนดเฉพาะของภาพเคลื่อนไหวที่กำหนดเองได้ด้วย หากต้องการ โดยทำดังนี้

LazyColumn {
    items(books, key = { it.id }) {
        Row(
            Modifier.animateItem(
                fadeInSpec = tween(durationMillis = 250),
                fadeOutSpec = tween(durationMillis = 100),
                placementSpec = spring(stiffness = Spring.StiffnessLow, dampingRatio = Spring.DampingRatioMediumBouncy)
            )
        ) {
            // ...
        }
    }
}

ตรวจสอบว่าคุณได้ระบุคีย์สำหรับรายการต่างๆ เพื่อให้ค้นหาตำแหน่งใหม่ขององค์ประกอบที่ย้ายได้

ตัวอย่าง: ทำให้รายการเคลื่อนไหวใน Lazy List

เมื่อใช้ Compose คุณจะทำให้การเปลี่ยนแปลงรายการใน Lazy List เป็นภาพเคลื่อนไหวได้ เมื่อใช้ร่วมกัน ข้อมูลโค้ดต่อไปนี้จะใช้ภาพเคลื่อนไหวเมื่อเพิ่ม นำออก และจัดเรียงรายการ Lazy List ใหม่

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

@Composable
fun ListAnimatedItems(
    items: List<String>,
    modifier: Modifier = Modifier
) {
    LazyColumn(modifier) {
        // Use a unique key per item, so that animations work as expected.
        items(items, key = { it }) {
            ListItem(
                headlineContent = { Text(it) },
                modifier = Modifier
                    .animateItem(
                        // Optionally add custom animation specs
                    )
                    .fillParentMaxWidth()
                    .padding(horizontal = 8.dp, vertical = 0.dp),
            )
        }
    }
}

ประเด็นสำคัญเกี่ยวกับโค้ด

  • ListAnimatedItems แสดงรายการสตริงใน LazyColumn พร้อมการเปลี่ยนภาพเคลื่อนไหวเมื่อมีการแก้ไขรายการ
  • ฟังก์ชัน items จะกำหนดคีย์ที่ไม่ซ้ำกันให้กับแต่ละรายการในลิสต์ Compose จะใช้คีย์เพื่อติดตามรายการและระบุการเปลี่ยนแปลงตำแหน่งของรายการ
  • ListItem กำหนดเลย์เอาต์ของรายการแต่ละรายการในลิสต์ โดยจะใช้พารามิเตอร์ headlineContent ซึ่งกำหนดเนื้อหาหลักของรายการ
  • ตัวแก้ไข animateItem จะใช้ภาพเคลื่อนไหวเริ่มต้นกับการเพิ่ม การนำออก และการย้ายรายการ

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

@Composable
private fun ListAnimatedItemsExample(
    data: List<String>,
    modifier: Modifier = Modifier,
    onAddItem: () -> Unit = {},
    onRemoveItem: () -> Unit = {},
    resetOrder: () -> Unit = {},
    onSortAlphabetically: () -> Unit = {},
    onSortByLength: () -> Unit = {},
) {
    val canAddItem = data.size < 10
    val canRemoveItem = data.isNotEmpty()

    Scaffold(modifier) { paddingValues ->
        Column(
            modifier = Modifier
                .padding(paddingValues)
                .fillMaxSize()
        ) {
            // Buttons that change the value of displayedItems.
            AddRemoveButtons(canAddItem, canRemoveItem, onAddItem, onRemoveItem)
            OrderButtons(resetOrder, onSortAlphabetically, onSortByLength)

            // List that displays the values of displayedItems.
            ListAnimatedItems(data)
        }
    }
}

ประเด็นสำคัญเกี่ยวกับโค้ด

  • ListAnimatedItemsExample แสดงหน้าจอที่มีการควบคุมสำหรับการเพิ่ม นำออก และจัดเรียงรายการ
    • onAddItem และ onRemoveItem เป็นนิพจน์ Lambda ที่ส่งไปยัง AddRemoveButtons เพื่อเพิ่มและนำรายการออกจากลิสต์
    • resetOrder, onSortAlphabetically และ onSortByLength เป็นนิพจน์ Lambda ที่ส่งไปยัง OrderButtons เพื่อเปลี่ยนลำดับของรายการในลิสต์
  • AddRemoveButtons แสดงปุ่ม "เพิ่ม" และ "นำออก" โดยจะเปิด/ปิดใช้ปุ่มและจัดการการคลิกปุ่ม
  • OrderButtons แสดงปุ่มสำหรับจัดเรียงรายการใหม่ โดยจะรับฟังก์ชัน Lambda สำหรับการรีเซ็ตลำดับและการจัดเรียงรายการตามความยาวหรือตามตัวอักษร
  • ListAnimatedItems เรียกใช้ Composable ListAnimatedItems โดยส่งรายการ data เพื่อแสดงรายการสตริงแบบเคลื่อนไหว มีการกำหนด data ไว้ที่อื่น

ข้อมูลโค้ดนี้จะสร้าง UI ที่มีปุ่มเพิ่มรายการและลบรายการ

@Composable
private fun AddRemoveButtons(
    canAddItem: Boolean,
    canRemoveItem: Boolean,
    onAddItem: () -> Unit,
    onRemoveItem: () -> Unit
) {
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.Center
    ) {
        Button(enabled = canAddItem, onClick = onAddItem) {
            Text("Add Item")
        }
        Spacer(modifier = Modifier.padding(25.dp))
        Button(enabled = canRemoveItem, onClick = onRemoveItem) {
            Text("Delete Item")
        }
    }
}

ประเด็นสำคัญเกี่ยวกับโค้ด

  • AddRemoveButtons แสดงแถวของปุ่มเพื่อดำเนินการเพิ่มและนำออกในรายการ
  • พารามิเตอร์ canAddItem และ canRemoveItem จะควบคุมสถานะการเปิดใช้ของปุ่ม หาก canAddItem หรือ canRemoveItem เป็นเท็จ ปุ่มที่เกี่ยวข้องจะปิดใช้
  • พารามิเตอร์ onAddItem และ onRemoveItem เป็น Lambda ที่จะทำงานเมื่อผู้ใช้คลิกปุ่มที่เกี่ยวข้อง

สุดท้าย ข้อมูลโค้ดนี้จะแสดงปุ่ม 3 ปุ่มสำหรับการจัดเรียงรายการ (รีเซ็ต ตามตัวอักษร และความยาว) ดังนี้

@Composable
private fun OrderButtons(
    resetOrder: () -> Unit,
    orderAlphabetically: () -> Unit,
    orderByLength: () -> Unit
) {
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.Center
    ) {
        var selectedIndex by remember { mutableIntStateOf(0) }
        val options = listOf("Reset", "Alphabetical", "Length")

        SingleChoiceSegmentedButtonRow {
            options.forEachIndexed { index, label ->
                SegmentedButton(
                    shape = SegmentedButtonDefaults.itemShape(
                        index = index,
                        count = options.size
                    ),
                    onClick = {
                        Log.d("AnimatedOrderedList", "selectedIndex: $selectedIndex")
                        selectedIndex = index
                        when (options[selectedIndex]) {
                            "Reset" -> resetOrder()
                            "Alphabetical" -> orderAlphabetically()
                            "Length" -> orderByLength()
                        }
                    },
                    selected = index == selectedIndex
                ) {
                    Text(label)
                }
            }
        }
    }
}

ประเด็นสำคัญเกี่ยวกับโค้ด

  • OrderButtons จะแสดง SingleChoiceSegmentedButtonRow เพื่อให้ผู้ใช้เลือกวิธีการจัดเรียงในรายการหรือรีเซ็ตลำดับรายการได้ คอมโพเนนต์ SegmentedButton ช่วยให้คุณเลือกตัวเลือกเดียวจากรายการตัวเลือกได้
  • resetOrder, orderAlphabetically และ orderByLength เป็นฟังก์ชัน Lambda ที่จะทำงานเมื่อเลือกปุ่มที่เกี่ยวข้อง
  • ตัวแปรสถานะ selectedIndex จะติดตามตัวเลือกที่เลือก

ผลลัพธ์

วิดีโอนี้แสดงผลลัพธ์ของข้อมูลโค้ดก่อนหน้าเมื่อมีการจัดเรียงรายการใหม่

รูปที่ 1 รายการที่แสดงภาพเคลื่อนไหวการเปลี่ยนรายการเมื่อมีการเพิ่ม นำออก หรือจัดเรียงรายการ

ส่วนหัวแบบติดหนึบ

รูปแบบ "ส่วนหัวแบบติดหนึบ" จะมีประโยชน์เมื่อแสดงรายการข้อมูลที่จัดกลุ่ม ด้านล่างนี้คือตัวอย่างของ "ข้อมูลรายชื่อติดต่อ" ซึ่งจัดกลุ่มตามตัวอักษรขึ้นต้นของรายชื่อติดต่อแต่ละราย

วิดีโอโทรศัพท์ที่เลื่อนขึ้นและลงในข้อมูลรายชื่อติดต่อ

หากต้องการใช้ส่วนหัวแบบติดหนึบกับ LazyColumn คุณสามารถใช้ stickyHeader()

ซึ่งจะแสดงเนื้อหาส่วนหัวดังนี้

@Composable
fun ListWithHeader(items: List<Item>) {
    LazyColumn {
        stickyHeader {
            Header()
        }

        items(items) { item ->
            ItemRow(item)
        }
    }
}

หากต้องการสร้างรายการที่มีส่วนหัวหลายรายการ เช่น ตัวอย่าง "ข้อมูลรายชื่อติดต่อ" ด้านบน คุณสามารถทำดังนี้

// This ideally would be done in the ViewModel
val grouped = contacts.groupBy { it.firstName[0] }

@Composable
fun ContactsList(grouped: Map<Char, List<Contact>>) {
    LazyColumn {
        grouped.forEach { (initial, contactsForInitial) ->
            stickyHeader {
                CharacterHeader(initial)
            }

            items(contactsForInitial) { contact ->
                ContactListItem(contact)
            }
        }
    }
}

การตอบสนองต่อตำแหน่งการเลื่อน

แอปจำนวนมากต้องตอบสนองและคอยฟังตำแหน่งการเลื่อนและการเปลี่ยนแปลงเลย์เอาต์ของรายการ คอมโพเนนต์ Lazy รองรับกรณีการใช้งานนี้โดยการโฮสต์ LazyListState ดังนี้

@Composable
fun MessageList(messages: List<Message>) {
    // Remember our own LazyListState
    val listState = rememberLazyListState()

    // Provide it to LazyColumn
    LazyColumn(state = listState) {
        // ...
    }
}

สำหรับ Use Case แบบง่าย โดยทั่วไปแอปจำเป็นต้องทราบข้อมูลเกี่ยวกับรายการแรกที่มองเห็นเท่านั้น สำหรับกรณีนี้ LazyListState มีพร็อพเพอร์ตี้ firstVisibleItemIndex และ firstVisibleItemScrollOffset

หากเราใช้ตัวอย่างการแสดงและซ่อนปุ่มโดยอิงตามว่าผู้ใช้เลื่อนผ่านรายการแรกไปแล้วหรือไม่

@Composable
fun MessageList(messages: List<Message>) {
    Box {
        val listState = rememberLazyListState()

        LazyColumn(state = listState) {
            // ...
        }

        // Show the button if the first visible item is past
        // the first item. We use a remembered derived state to
        // minimize unnecessary compositions
        val showButton by remember {
            derivedStateOf {
                listState.firstVisibleItemIndex > 0
            }
        }

        AnimatedVisibility(visible = showButton) {
            ScrollToTopButton()
        }
    }
}

การอ่านสถานะโดยตรงใน Composition จะมีประโยชน์เมื่อคุณต้องการอัปเดต Composable ของ UI อื่นๆ แต่ก็มีสถานการณ์ที่เหตุการณ์ไม่จำเป็นต้องได้รับการจัดการใน Composition เดียวกันด้วย ตัวอย่างที่พบบ่อยคือการส่งเหตุการณ์ Analytics เมื่อผู้ใช้เลื่อนผ่านจุดหนึ่งๆ เราสามารถใช้ snapshotFlow() เพื่อจัดการเรื่องนี้ได้อย่างมีประสิทธิภาพ ดังนี้

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .map { index -> index > 0 }
        .distinctUntilChanged()
        .filter { it }
        .collect {
            MyAnalyticsService.sendScrolledPastFirstItemEvent()
        }
}

นอกจากนี้ LazyListState ยังให้ข้อมูลเกี่ยวกับรายการทั้งหมดที่แสดงอยู่ในปัจจุบันและขอบเขตของรายการบนหน้าจอผ่านพร็อพเพอร์ตี้ layoutInfo ด้วย ดูข้อมูลเพิ่มเติมได้ที่คลาส LazyListLayoutInfo

การควบคุมตำแหน่งการเลื่อน

นอกจากจะตอบสนองต่อตำแหน่งการเลื่อนแล้ว แอปยังสามารถควบคุมตำแหน่งการเลื่อนได้ด้วย LazyListState รองรับการดำเนินการนี้ผ่านฟังก์ชัน scrollToItem() ซึ่งจะเลื่อนไปยังตำแหน่งที่ต้องการ "ทันที" และ animateScrollToItem() ซึ่งจะเลื่อนโดยใช้ภาพเคลื่อนไหว (หรือที่เรียกว่าการเลื่อนอย่างราบรื่น) ดังนี้

@Composable
fun MessageList(messages: List<Message>) {
    val listState = rememberLazyListState()
    // Remember a CoroutineScope to be able to launch
    val coroutineScope = rememberCoroutineScope()

    LazyColumn(state = listState) {
        // ...
    }

    ScrollToTopButton(
        onClick = {
            coroutineScope.launch {
                // Animate scroll to the first item
                listState.animateScrollToItem(index = 0)
            }
        }
    )
}

ชุดข้อมูลขนาดใหญ่ (การแบ่งหน้า)

ไลบรารีเพจจิ้งช่วยให้แอปสามารถรองรับรายการสินค้าจำนวนมาก โดยโหลดและแสดงรายการสินค้าเป็นกลุ่มเล็กๆ ตามความจำเป็น Paging 3.0 ขึ้นไปรองรับ Compose ผ่านไลบรารี androidx.paging:paging-compose

หากต้องการแสดงรายการเนื้อหาที่มีการแบ่งหน้า เราสามารถใช้ฟังก์ชันส่วนขยาย collectAsLazyPagingItems() แล้วส่ง LazyPagingItems ที่แสดงผลไปยัง items() ใน LazyColumn คุณสามารถแสดงตัวยึดตำแหน่งขณะโหลดข้อมูลได้โดยตรวจสอบว่า item เป็น null หรือไม่ ซึ่งคล้ายกับการรองรับการแบ่งหน้าในมุมมอง

@Composable
fun MessageList(pager: Pager<Int, Message>) {
    val lazyPagingItems = pager.flow.collectAsLazyPagingItems()

    LazyColumn {
        items(
            lazyPagingItems.itemCount,
            key = lazyPagingItems.itemKey { it.id }
        ) { index ->
            val message = lazyPagingItems[index]
            if (message != null) {
                MessageRow(message)
            } else {
                MessagePlaceholder()
            }
        }
    }
}

เคล็ดลับในการใช้เลย์เอาต์แบบ Lazy

เคล็ดลับบางประการที่คุณควรพิจารณาเพื่อให้เลย์เอาต์แบบ Lazy ทำงานได้ตามที่ต้องการมีดังนี้

หลีกเลี่ยงการใช้รายการที่มีขนาด 0 พิกเซล

กรณีนี้อาจเกิดขึ้นในสถานการณ์ต่างๆ เช่น คุณคาดว่าจะดึงข้อมูลบางอย่างแบบไม่พร้อมกัน เช่น รูปภาพ เพื่อเติมรายการในลิสต์ในภายหลัง ซึ่งจะทำให้เลย์เอาต์แบบ Lazy สร้างรายการทั้งหมดในการวัดครั้งแรก เนื่องจากรายการมีความสูง 0 พิกเซลและสามารถใส่ทั้งหมดลงในวิวพอร์ตได้ เมื่อโหลดรายการและขยายความสูงแล้ว เลย์เอาต์แบบ Lazy จะทิ้งรายการอื่นๆ ทั้งหมดที่คอมโพสโดยไม่จำเป็นในครั้งแรก เนื่องจากไม่พอดีกับวิวพอร์ต เพื่อหลีกเลี่ยงปัญหานี้ คุณควรกำหนดขนาดเริ่มต้นให้กับรายการ เพื่อให้เลย์เอาต์แบบ Lazy สามารถคำนวณจำนวนรายการที่พอดีกับวิวพอร์ตได้อย่างถูกต้อง

@Composable
fun Item(imageUrl: String) {
    AsyncImage(
        model = rememberAsyncImagePainter(model = imageUrl),
        modifier = Modifier.size(30.dp),
        contentDescription = null
        // ...
    )
}

เมื่อคุณทราบขนาดโดยประมาณของรายการหลังจากโหลดข้อมูลแบบไม่พร้อมกันแล้ว แนวทางปฏิบัติแนะนำคือตรวจสอบว่าขนาดของรายการยังคงเหมือนเดิมทั้งก่อนและหลังการโหลด เช่น โดยการเพิ่มตัวยึดตำแหน่งบางรายการ วิธีนี้จะช่วยรักษาตำแหน่งการเลื่อนที่ถูกต้อง

หลีกเลี่ยงการฝังคอมโพเนนต์ที่เลื่อนได้ในทิศทางเดียวกัน

ซึ่งจะมีผลเฉพาะกับกรณีที่ฝังองค์ประกอบย่อยที่เลื่อนได้ซึ่งไม่มีขนาดที่กำหนดไว้ล่วงหน้าภายในองค์ประกอบหลักที่เลื่อนได้ในทิศทางเดียวกันอีกรายการหนึ่ง ตัวอย่างเช่น การพยายามฝัง LazyColumn ย่อยที่ไม่มีความสูงคงที่ไว้ใน Column หลักที่เลื่อนได้ในแนวตั้ง

// throws IllegalStateException
Column(
    modifier = Modifier.verticalScroll(state)
) {
    LazyColumn {
        // ...
    }
}

แต่คุณสามารถบรรลุผลลัพธ์เดียวกันได้โดยการรวม Composable ทั้งหมดไว้ใน LazyColumn ระดับบนสุดรายการเดียวและใช้ DSL เพื่อส่งเนื้อหาประเภทต่างๆ ซึ่งจะช่วยให้คุณแสดงรายการเดียว รวมถึงรายการหลายรายการได้ในที่เดียว ดังนี้

LazyColumn {
    item {
        Header()
    }
    items(data) { item ->
        PhotoItem(item)
    }
    item {
        Footer()
    }
}

โปรดทราบว่ากรณีที่คุณฝังเลย์เอาต์ที่มีทิศทางต่างกัน เช่น Row หลักที่เลื่อนได้และ LazyColumn ย่อย จะได้รับอนุญาต

Row(
    modifier = Modifier.horizontalScroll(scrollState)
) {
    LazyColumn {
        // ...
    }
}

รวมถึงกรณีที่คุณยังคงใช้เลย์เอาต์ทิศทางเดียวกัน แต่ตั้งค่าขนาดคงที่ให้กับองค์ประกอบย่อยที่ฝังอยู่ด้วย ดังนี้

Column(
    modifier = Modifier.verticalScroll(scrollState)
) {
    LazyColumn(
        modifier = Modifier.height(200.dp)
    ) {
        // ...
    }
}

ระวังการใส่องค์ประกอบหลายรายการในรายการเดียว

ในตัวอย่างนี้ แลมบ์ดาของรายการที่ 2 จะแสดงผล 2 รายการในบล็อกเดียว

LazyVerticalGrid(
    columns = GridCells.Adaptive(100.dp)
) {
    item { Item(0) }
    item {
        Item(1)
        Item(2)
    }
    item { Item(3) }
    // ...
}

เลย์เอาต์แบบ Lazy จะจัดการเรื่องนี้ตามที่คาดไว้ โดยจะจัดวางองค์ประกอบต่อกันราวกับว่าเป็นรายการที่แตกต่างกัน อย่างไรก็ตาม การทำเช่นนี้ มีปัญหาอยู่ 2-3 ประการ

เมื่อมีการปล่อยองค์ประกอบหลายรายการเป็นส่วนหนึ่งของรายการเดียว ระบบจะจัดการองค์ประกอบเหล่านั้นเป็นเอนทิตีเดียว ซึ่งหมายความว่าจะไม่สามารถจัดองค์ประกอบแยกกันได้อีกต่อไป หากองค์ประกอบหนึ่งปรากฏบนหน้าจอ องค์ประกอบทั้งหมดที่สอดคล้องกับรายการนั้นจะต้องได้รับการจัดองค์ประกอบและวัดค่า ซึ่งอาจส่งผลเสียต่อประสิทธิภาพหากใช้มากเกินไป ในกรณีที่รุนแรงของการวางองค์ประกอบทั้งหมดไว้ในรายการเดียว จะทำให้วัตถุประสงค์ของการใช้เลย์เอาต์แบบ Lazy ไม่เป็นผลโดยสิ้นเชิง นอกเหนือจากปัญหาด้านประสิทธิภาพที่อาจเกิดขึ้นแล้ว การใส่องค์ประกอบหลายรายการในรายการเดียวจะรบกวน scrollToItem() และ animateScrollToItem() ด้วย

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

LazyVerticalGrid(
    columns = GridCells.Adaptive(100.dp)
) {
    item { Item(0) }
    item {
        Item(1)
        Divider()
    }
    item { Item(2) }
    // ...
}

พิจารณาใช้การจัดเรียงที่กำหนดเอง

โดยปกติแล้ว Lazy List จะมีรายการจำนวนมาก และใช้พื้นที่มากกว่าขนาดของคอนเทนเนอร์การเลื่อน อย่างไรก็ตาม เมื่อรายการมีข้อมูลเพียงไม่กี่รายการ การออกแบบของคุณอาจมีข้อกำหนดที่เฉพาะเจาะจงมากขึ้นเกี่ยวกับวิธีจัดตำแหน่งรายการเหล่านี้ในวิวพอร์ต

ซึ่งทำได้โดยใช้ Arrangement แนวตั้งที่กำหนดเองและส่งไปยัง LazyColumn ในตัวอย่างต่อไปนี้ ออบเจ็กต์ TopWithFooter ต้องใช้เมธอด arrange เท่านั้น ประการแรก ระบบจะจัดตำแหน่งรายการต่อกันไปเรื่อยๆ ประการที่ 2 หากความสูงที่ใช้ทั้งหมดต่ำกว่าความสูงของวิวพอร์ต ระบบจะวางส่วนท้ายไว้ที่ด้านล่าง ดังนี้

object TopWithFooter : Arrangement.Vertical {
    override fun Density.arrange(
        totalSize: Int,
        sizes: IntArray,
        outPositions: IntArray
    ) {
        var y = 0
        sizes.forEachIndexed { index, size ->
            outPositions[index] = y
            y += size
        }
        if (y < totalSize) {
            val lastIndex = outPositions.lastIndex
            outPositions[lastIndex] = totalSize - sizes.last()
        }
    }
}

พิจารณาเพิ่ม contentType

ตั้งแต่ Compose 1.2 เป็นต้นไป หากต้องการเพิ่มประสิทธิภาพของเลย์เอาต์แบบ Lazy ให้สูงสุด ให้ลองเพิ่ม contentType ลงในรายการหรือตารางกริด ซึ่งช่วยให้คุณระบุประเภทเนื้อหาสำหรับแต่ละรายการของเลย์เอาต์ได้ ในกรณีที่คุณกำลังเขียนรายการหรือตารางกริดที่ประกอบด้วยรายการประเภทต่างๆ หลายรายการ ดังนี้

LazyColumn {
    items(elements, contentType = { it.type }) {
        // ...
    }
}

เมื่อคุณระบุ contentType Compose จะใช้ Composition ซ้ำได้เฉพาะระหว่างรายการประเภทเดียวกันเท่านั้น เนื่องจากการนำกลับมาใช้ใหม่จะมีประสิทธิภาพมากกว่าเมื่อคุณเขียนรายการที่มีโครงสร้างคล้ายกัน การระบุประเภทเนื้อหาจะช่วยให้มั่นใจได้ว่า Compose จะไม่พยายามเขียนรายการประเภท A ทับรายการประเภท B ที่แตกต่างกันโดยสิ้นเชิง ซึ่งจะช่วยเพิ่มประโยชน์สูงสุดจากการนำ Composition กลับมาใช้ใหม่และประสิทธิภาพของเลย์เอาต์แบบ Lazy

การวัดประสิทธิภาพ

คุณจะวัดประสิทธิภาพของเลย์เอาต์แบบ Lazy ได้อย่างน่าเชื่อถือก็ต่อเมื่อเรียกใช้ในโหมด Release และเปิดใช้การเพิ่มประสิทธิภาพ R8 เท่านั้น ในบิลด์การแก้ไขข้อบกพร่อง การเลื่อนเลย์เอาต์แบบ Lazy อาจดูช้าลง ดูข้อมูลเพิ่มเติมเกี่ยวกับเรื่องนี้ได้ที่ประสิทธิภาพของ Compose

แหล่งข้อมูลเพิ่มเติม