清單和格線

許多應用程式都需要顯示項目集合。本文將說明如何在 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 的大多數版面配置不同。而不是 接受 @Composable 內容區塊參數,讓應用程式直接 發出可組合函式,Lazy 元件則提供 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)
    }
}

此外,此外, items() 呼叫了擴充功能函式 itemsIndexed()、 並提供索引詳情請參閱 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 的 狀態。Bundle 支援基元等類型 列舉或 Parcelable

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

金鑰必須由 Bundle 支援,以便內部的 rememberSaveable 重新建立 Activity 時即可還原商品可組合函式 您向外捲動畫面並捲動回頭,

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

項目動畫

如果用過 RecyclerView 小工具,就會知道該小工具會為項目建立動畫效果 變更。 Lazy 版面配置提供相同的項目重新排序功能。該 API 原理簡單 - 您只需要將 animateItemPlacement 修飾元設為項目內容即可:

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 可組合項時,直接在組合中讀取狀態會很有用,但在某些情況下,事件不需要在相同組合中來處理。常見的例子是 當使用者捲動網頁經過某個時間點時,就會產生 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)
            }
        }
    )
}

大型資料集 (分頁)

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

如要顯示分頁內容清單,可以使用 collectAsLazyPagingItems() 然後再傳入傳回的 LazyPagingItems 修改了《LazyColumn》中的 items()。與檢視畫面的分頁支援類似,您可以 檢查 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 版面配置捲動顯示的速度可能較慢。如需更多相關資訊,請詳閱 Compose 效能