清單和格線

許多應用程式都需要顯示項目集合。本文將說明如何在 Jetpack Compose 中有效率地進行這項操作。

如果您知道在自己的使用情境中無需捲動功能,不妨使用簡單的 ColumnRow (視方向而定),然後透過疊代清單來發出各個項目,如下所示:

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

您可以使用 verticalScroll() 修飾符讓 Column 可捲動。

Lazy 清單

無論項目是否可供查看,系統都會對所有項目進行組合和配置,因此如果您需要顯示大量項目,或長度未知的清單,使用 Column 之類的版面配置可能會造成效能問題。

Compose 提供了一組元件,這些元件只會組合和配置會顯示在元件可視區域中的項目。這些元件包括 LazyColumnLazyRow

顧名思義,LazyColumnLazyRow 之間的差別在於項目配置和捲動的方向不同。LazyColumn 會產生垂直捲動清單,LazyRow 則會產生水平捲動清單。

Lazy 元件與 Compose 的大多數版面配置不同。Lazy 元件不是接受 @Composable 內容區塊參數,讓應用程式直接發出可組合項,而是提供一個 LazyListScope.() 區塊。這個 LazyListScope 區塊提供 DSL,可讓應用程式說明項目內容。接著,Lazy 元件會負責依照版面配置和捲動位置的要求,新增各個項目的內容。

LazyListScope DSL

LazyListScope 的 DSL 會提供多個函式,用於描述版面配置中的項目。最基本的 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)
    }
}

此外,還有一個名為 itemsIndexed()items() 擴充功能函式變體,可用於提供索引。詳情請參閱 LazyListScope 參考資料。

Lazy 格線

LazyVerticalGridLazyHorizontalGrid 可組合項支援在格線中顯示項目。Lazy 垂直格線會在橫跨多欄的垂直捲動式容器中顯示項目,而 Lazy 水平格線在水平軸上會有相同的行為。

格線具有與清單相同的強大 API 功能,而且也使用非常類似的 DSL - LazyGridScope.() 來說明內容。

以格狀檢視方式顯示相片的手機螢幕截圖

LazyVerticalGrid 中的 columns 參數和 LazyHorizontalGrid 中的 rows 參數可控管儲存格轉換為欄或列的方式。以下範例將以格狀檢視方式顯示項目,並使用 GridCells.Adaptive 將每欄的寬度設為至少 128.dp

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

LazyVerticalGrid 可讓您為項目指定寬度,然後格線會盡可能容納更多的欄。計算欄數後,所有剩餘的寬度會平均分配給各欄。這種自動調整尺寸的方式特別適合用來顯示不同螢幕尺寸的項目組合。

如果您知道要使用的確切欄數,可以改為提供包含所需欄數的 GridCells.Fixed 例項。

如果設計僅需要某些項目具有非標準尺寸,您可以使用格線支援功能,為項目提供自訂欄時距。使用 LazyGridScope DSL itemitems 方法的 span 參數指定欄時距。由於沒有規定欄數,在您自動調整尺寸功能時,maxLineSpan,即其中一個時距範圍的值,尤為有用。以下範例說明如何提供完整的列時距:

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

延遲交錯方格

LazyVerticalStaggeredGridLazyHorizontalStaggeredGrid 是可組合函式,可讓您建立延遲載入的項目格線。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. 延遲的垂直格線排列示例

如要設定固定的欄數,您可以使用 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()
)
Compose 中的延遲交錯圖片格狀檢視畫面
圖 2. 使用固定欄數的延遲交錯垂直格狀結構範例

內容間距

您有時候必須在內容邊緣周圍加上邊框間距,延遲元件可讓您將一些 PaddingValues 傳遞至 contentPadding 參數來支援這項功能:

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

在這個範例中,我們在水平邊緣 (左側和右側) 加入 16.dp 邊框間距,然後在內容的頂端和底部加入 8.dp

請注意,此邊框間距會套用到內容,而非 LazyColumn 本身。在上述範例中,第一個項目會在頂端加入 8.dp 邊框間距,最後一個項目會在底部加入 8.dp,且所有項目的左側和右側都將具有 16.dp 邊框間距。

內容間距

如要在項目之間加入間距,可以使用 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)
    }
}

項目鍵

根據預設,每個項目的狀態都會與項目或格線在清單中的位置對應。但是,如果資料集發生變化,就可能會造成問題,因為變更位置的項目實際上會遺失任何記憶狀態。想像一下 LazyColumn 中的 LazyRow 情境,如果某一列變更項目位置,使用者就會遺失該列內的捲動位置。

如要解決這個問題,請為每個項目提供穩定的專屬鍵,為 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()
        }
    }
}

不過,系統對可以做為項目鍵的類型施加了一個限制。鍵的類型必須受 Bundle 支援,這是 Android 用來在重新建立 Activity 時維持狀態的機制。Bundle 支援基元、列舉或 Parcelables 等類型。

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

鍵必須受 Bundle 支援,因此在重新建立 Activity 時,甚至在捲動離開和返回該項目時,可組合項中的 rememberSaveable 也能還原。

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)
            )
        ) {
            // ...
        }
    }
}

確定您為項目提供鍵,以便系統為移動的元素找到新的位置。

固定式標頭 (實驗功能)

顯示分組資料清單時,「固定式標頭」模式相當實用。下方為「聯絡人清單」範例,其中以每位聯絡人的名稱首字母分組:

影片:使用手機上下捲動瀏覽聯絡人清單

如要透過 LazyColumn 做到固定式標頭,您可以使用實驗性 stickyHeader() 函式來提供標頭內容:

@OptIn(ExperimentalFoundationApi::class)
@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] }

@OptIn(ExperimentalFoundationApi::class)
@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) {
        // ...
    }
}

針對簡易的使用情境,應用程式通常只需要瞭解第一個可視項目的相關資訊。為此,LazyListState 提供 firstVisibleItemIndexfirstVisibleItemScrollOffset 屬性。

以下範例會根據使用者是否曾捲動經過第一個項目顯示及隱藏按鈕:

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

當您需要更新其他 UI 可組合項時,直接在組合中讀取狀態會很有用,但在某些情況下,事件不需要在相同組合中來處理。常見的情形是,當使用者捲動經過某個點時,系統就會傳送分析事件。為了有效處理這種情況,我們可以使用 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)
            }
        }
    )
}

大型資料集 (分頁)

分頁程式庫可讓應用程式支援大量項目清單,並視需要載入和顯示清單上的小區塊。分頁程式庫 3.0 以上版本會透過 androidx.paging:paging-compose 程式庫提供 Compose 支援。

如要顯示分頁內容清單,可以使用 collectAsLazyPagingItems() 擴充功能函式,然後將返回的 LazyPagingItems 傳遞至 LazyColumnitems()。與檢視畫面的分頁支援類似,您可以透過檢查 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
        // ...
    )
}

在系統以非同步方式載入資料後,當您知道項目的約略尺寸時,良好的做法是,確保項目的尺寸在載入前後保持不變 (例如透過加入一些預留位置做到這點)。這有助於維持正確的捲動位置。

避免以巢狀方式嵌入可往相同方向捲動的元件

這種做法僅適用於以下情況:將可捲動子項以巢狀方式嵌入另一個可往相同方向捲動的父項內,且並未預先定義尺寸。例如,嘗試以巢狀方式載入在垂直可捲動的 Column 父項內沒有固定高度的子項 LazyColumn

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

相反,您可以將所有可組合項納入一個父項 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)
    ) {
        // ...
    }
}

將多個元素放在同一項目中時的注意事項

在這個範例中,第二個項目 lambda 在一個區塊中發出了 2 個項目:

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

Lazy 版面配置會按正常方式處理此情況,也就是依序排進這些元素,就像這些元素是不同項目一樣。但這樣做仍有幾個問題。

當多個元素當做某個項目的一部分發出時,系統會將這些元素視為一個實體,因此無法再個別組合這些元素。如果其中一個元素可以顯示在畫面上,則所有與該項目相對應的元素都必須經過組合和測量。過度使用時,這可能會對效能造成負面影響。萬一所有元素都放在同一個項目中,就完全無法達到使用 Lazy 版面配置的目的。除了潛在的效能問題之外,將多個元素放入同一個項目中也會干擾 scrollToItem()animateScrollToItem()

不過,將多個元素放在一個項目中也存在有效的用途,例如在清單中加入分隔線。您不希望分隔線變更捲動索引,因為分隔線不應被視為獨立元素。此外,由於分隔線較小,因此效能不會受到影響。當分隔線前面的項目可顯示時,分隔線也很可能需要顯示出來,以便成為前一個項目的一部分:

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

考慮使用自訂排列方式

一般來說,Lazy 清單內含許多項目,其占用空間會超出捲動容器的大小。但是,當以少量項目填入您的清單時,您的設計就能對這些項目在可視區域中的位置做出更具體的要求。

為此,您可以使用自訂垂直 Arrangement,並傳送至 LazyColumn。在以下範例中,TopWithFooter 物件只需要實作 arrange 方法即可。首先,該方法會依序安排項目的位置。其次,如果已使用的總高度低於可視區域高度,該方法會將頁尾置於底部:

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 即可重複使用僅包含同一類型項目的組合。由於提供內容類型可確保 Compose 不會嘗試在 A 類型項目的上方組合完全不同的 B 類型項目,因此在組合結構類似的項目時,可透過重複使用的做法提升效率。這樣有助於您有效利用重複使用撰寫及 Lazy 版面配置效能所帶來的優勢。

評估成效

只有在發布模式下執行並啟用 R8 最佳化功能時,您才能穩定評估 Lazy 版面配置的效能。在偵錯版本中,Lazy 版面配置捲動顯示的速度可能較慢。如要進一步瞭解相關資訊,請參閱「Compose 效能」一文。