清單和格線

許多應用程式都需要顯示項目集合。本文件將說明 這在 Jetpack Compose 中能有效率地完成

如果您知道您的應用程式將不需要捲動任何頁面,建議您 使用簡單的 ColumnRow (視方向而定),並由以下做法發出各個項目的內容: 以下列方式疊代清單:

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

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

Lazy 清單

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

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

顧名思義,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.()敬上 描述內容

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

以下程式碼中的 columns 參數: LazyVerticalGridrows 參數 LazyHorizontalGrid 控制儲存格轉換為欄或列的方式。下列 範例會使用 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 水平格線在 Google 地球上 各個寬度的項目

以下程式碼片段是使用 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 支援,以便內部的 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)
            )
        ) {
            // ...
        }
    }
}

請務必為商品設定鍵,我們才能為新的項目提供新的鍵 。

除了重新排序外,目前仍在開發新增和移除的項目動畫。如要追蹤進度,請前往 問題 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 資源。

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

@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() 函式支援這項操作,前者會「立即」捕捉捲動位置,而後者會使用動畫進行捲動 (也稱為流暢捲動):

@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 程式庫。

如要顯示分頁內容清單,可以使用 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 效能