清單

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

如果您知道用途不需要捲動,不妨使用簡單的 ColumnRow (視方向而定),然後逐一對應清單內容,例如:

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

您可以使用 verticalScroll() 修飾元,讓 Column 捲動。詳情請參閱手勢說明文件。

Lazy composables

如果您需要顯示大量項目 (或不明長度的清單),使用 Column 等版面配置可能會導致效能問題,因為無論它們是否可見,所有項目都將被組合和佈局。

Compose 提供一組元件,這個元件只會撰寫和編排要顯示在元件可視區域中的項目。這些元件包含 LazyColumnLazyRow

顧名思義,LazyColumnLazyRow 的差異在於其擺放方向,也就是項目和捲動畫面。LazyColumn 會產生垂直捲動清單,LazyRow 則會產生水平捲動清單。

「延遲」元件與 Compose 的大多數版面配置不同。請勿接受@Composable內容區塊參數可讓應用程式直接發出可組合元件,惰性組件提供了一個 LazyListScope.() 區塊。這個 LazyListScope 區塊提供 DSL,可讓應用程式「說明」項目內容。延遲元件會依照版面配置和捲動位置的需求,新增各個項目的內容。

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

@Composable
fun MessageList(messages: List<Message>) {
    LazyColumn {
        items(messages) { message ->
            MessageRow(message)
        }
    }
}

此外,items() 擴充功能的變化版本也稱為itemsIndexed(),可提供索引。詳情請參閱 LazyListScope 參考資料。

內容間距

在某些情況下,您必須在內容邊緣周圍加上邊框間距。延遲元件可讓您將 PaddingValues 傳送至 contentPadding 參數來支援以下功能:

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

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

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

Content spacing

如要在項目之間加入間距,您可以使用 Arrangement.spacedBy()。以下範例會在每個項目之間插入 4.dp 的空間:

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

同樣地,LazyRow 的情況如下:

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

項目動畫

如果您使用了 RecyclerView 小工具,就可以知道該功能會自動變更動畫項目。「延遲」版面配置「尚未」提供該功能,這表示項目變更會導致即時「snap」。您可以追蹤這個錯誤,追蹤這項功能的任何變更。

固定式標頭(實驗功能)

顯示分組資料清單時,「固定式標頭」模式相當實用。您可以參考下方的「聯絡人清單」範例,並按照每位聯絡人的初始名稱分組:

手機透過聯絡人清單向上和向下捲動

如要利用 LazyColumn 實現固定式標頭,您可以使用實驗的 stickyHeader() 函式提供標題內容:

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

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

若您想取得具有多個標頭的清單 (例如上方的「聯絡人清單」範例),可以:

// TODO: 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)
            }
        }
    }
}

GRid(實驗功能)

LazyVerticalGrid 元件提供實驗支援,以格狀方式顯示項目。

顯示相片格狀檢視畫面的手機螢幕截圖

cells 參數可控制儲存格構成資料欄的方式。以下範例顯示格狀檢視中的項目,並使用 GridCells.Adaptive 將每個資料欄設為至少 128.dp 寬:

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun PhotoGrid(photos: List<Photo>) {
    LazyVerticalGrid(
        cells = GridCells.Adaptive(minSize = 128.dp)
    ) {
        items(photos) { photo ->
            PhotoItem(photo)
        }
    }
}

如果您知道要使用的資料欄數量,可以改為提供包含必要資料欄數量的 GridCells.Fixed 例項。

捲動至捲動位置

許多應用程式都必須回應並聽取捲動位置與項目版面配置變更。延遲元件能藉由傳遞 LazyListState 來支援這種用途:

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

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

對於簡易的用途,應用程式通常只需要瞭解第一個可見項目的相關資訊。如要 LazyListState 提供 firstVisibleItemIndex 以及 firstVisibleItemScrollOffset 屬性。

假設 Google 會根據使用者捲動過第一個項目時顯示及隱藏按鈕,而以下列做法為例:

@OptIn(ExperimentalAnimationApi::class) // AnimatedVisibility
@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 == true }
        .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()擴充功能,然後在 LazyColumn 中將傳遞傳回的LazyPagingItems 傳給 items()。與資料檢視的分頁支援類似,您可以透過檢查 item 是否為 null,在載入資料時顯示預留位置:

import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.items

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

    LazyColumn {
        items(lazyPagingItems) { message ->
            if (message != null) {
                MessageRow(message)
            } else {
                MessagePlaceholder()
            }
        }
    }
}

項目鍵

根據預設,每個項目的狀態都會與項目在清單中的位置有關。但是,如果資料集變更,就可能會導致問題,因為變更位置的項目會有效保留任何已記住的狀態。如果想像在 LazyColumn 中的 LazyRow 情境,只要資料列變更項目位置,使用者就會失去該資料列的捲動位置。

如要解決這個問題,請為每項項目提供穩定的專屬金鑰,為 key 參數提供區塊。提供固定的索引鍵可讓項目狀態在資料集變更保持一致:

@Composable
fun MessageList(messages: List<Message>) {
    LazyColumn {
        items(
            items = messages,
            key = { message ->
                // Return a stable + unique key for the item
                message.id
            }
        ) { message ->
            MessageRow(message)
        }
    }
}