清單和格線

許多應用程式都需要顯示項目集合。本文說明如何在 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 水平格線在具有不同寬度項目的水平軸上具有相同行為。

以下程式碼片段是使用 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 相當簡單,您只需將 animateItemPlacement 修飾符設為項目內容即可:

LazyColumn {
    items(books, key = { it.id }) {
        Row(Modifier.animateItemPlacement()) {
            // ...
        }
    }
}

如有下列需求,您甚至可以提供自訂動畫規格:

LazyColumn {
    items(books, key = { it.id }) {
        Row(
            Modifier.animateItemPlacement(
                tween(durationMillis = 250)
            )
        ) {
            // ...
        }
    }
}

請務必為項目提供鍵,以便為移動的元素找到新位置。

除了重新排序之外,目前仍在開發新增和移除的項目動畫。您可以在問題 150812265 中追蹤進度。

固定式標頭 (實驗功能)

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

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

如要透過 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 屬性。

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

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

大型資料集 (分頁)

分頁程式庫可讓應用程式支援大量項目清單,並視需要載入和顯示清單的小區塊。Paging 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 版面配置的效能。在偵錯版本中,Lazy 版面配置捲動顯示的速度可能較慢。如要進一步瞭解相關資訊,請參閱 Compose 效能一文。