Списки и сетки

Многим приложениям необходимо отображать коллекции элементов. В этом документе объясняется, как можно эффективно сделать это в Jetpack Compose.

Если вы знаете, что ваш вариант использования не требует прокрутки, вы можете использовать простой Column или Row (в зависимости от направления) и выдавать содержимое каждого элемента, перебирая список следующим образом:

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

Мы можем сделать Column прокручиваемым с помощью модификатораverticalScroll verticalScroll() .

Ленивые списки

Если вам нужно отобразить большое количество элементов (или список неизвестной длины), использование такого макета, как Column может вызвать проблемы с производительностью, поскольку все элементы будут составлены и размещены независимо от того, видимы они или нет.

Compose предоставляет набор компонентов, которые составляют и размещают только те элементы, которые видны в окне просмотра компонента. К этим компонентам относятся LazyColumn и LazyRow .

Как следует из названия, разница между LazyColumn и LazyRow заключается в ориентации, в которой они размещают свои элементы и прокручивают их. LazyColumn создает список с вертикальной прокруткой, а LazyRow создает список с горизонтальной прокруткой.

Компоненты Lazy отличаются от большинства макетов в Compose. Вместо принятия параметра блока контента @Composable , позволяющего приложениям напрямую создавать составные элементы, компоненты Lazy предоставляют блок LazyListScope.() . Этот блок LazyListScope предлагает DSL, который позволяет приложениям описывать содержимое элемента. Затем компонент Lazy отвечает за добавление содержимого каждого элемента в соответствии с макетом и положением прокрутки.

LazyListScope DSL

DSL LazyListScope предоставляет ряд функций для описания элементов макета. В самом простом случае 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 .

Ленивые сетки

Составные элементы LazyVerticalGrid и LazyHorizontalGrid обеспечивают поддержку отображения элементов в сетке. Ленивая вертикальная сетка будет отображать свои элементы в вертикально прокручиваемом контейнере, распределенном по нескольким столбцам, в то время как ленивые горизонтальные сетки будут иметь такое же поведение на горизонтальной оси.

Сетки обладают теми же мощными возможностями API, что и списки, а также используют очень похожий DSL — LazyGridScope.() для описания содержимого.

Скриншот телефона, показывающий сетку фотографий.

Параметр columns в LazyVerticalGrid и параметр rows в LazyHorizontalGrid управляют тем, как ячейки формируются в столбцы или строки. В следующем примере элементы отображаются в сетке, используя GridCells.Adaptive чтобы задать ширину каждого столбца не менее 128.dp :

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

LazyVerticalGrid позволяет указать ширину элементов, после чего сетка будет соответствовать как можно большему количеству столбцов. Оставшаяся ширина распределяется поровну между столбцами после расчета количества столбцов. Этот адаптивный способ изменения размера особенно полезен для отображения наборов элементов на экранах разных размеров.

Если вы знаете точное количество используемых столбцов, вместо этого вы можете предоставить экземпляр GridCells.Fixed , содержащий количество необходимых столбцов.

Если ваш дизайн требует, чтобы только определенные элементы имели нестандартные размеры, вы можете использовать поддержку сетки для предоставления настраиваемых интервалов столбцов для элементов. Укажите диапазон столбца с помощью параметра span item LazyGridScope DSL и методов items . maxLineSpan , одно из значений области диапазона, особенно полезно при использовании адаптивного размера, поскольку количество столбцов не фиксировано. В этом примере показано, как предоставить полный диапазон строк:

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

Ленивая шахматная сетка

LazyVerticalStaggeredGrid и LazyHorizontalStaggeredGrid — это составные элементы, которые позволяют создавать лениво загружаемую шахматную сетку элементов. Ленивая вертикальная шахматная сетка отображает элементы в контейнере с вертикальной прокруткой, который охватывает несколько столбцов и позволяет отдельным элементам иметь разную высоту. Ленивые горизонтальные сетки ведут себя одинаково по горизонтальной оси с элементами разной ширины.

Следующий фрагмент представляет собой базовый пример использования 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()
)
Lazy staggered grid of images in 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)
    }
}

Ключи предметов

По умолчанию состояние каждого элемента привязано к положению элемента в списке или сетке. Однако это может вызвать проблемы в случае изменения набора данных, поскольку элементы, меняющие положение, фактически теряют любое запомненное состояние. Если вы представите себе сценарий LazyRow внутри LazyColumn , то если строка изменит положение элемента, пользователь потеряет свою позицию прокрутки внутри строки.

Чтобы бороться с этим, вы можете предоставить стабильный и уникальный ключ для каждого элемента, предоставив блок 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 поддерживает такие типы, как примитивы, перечисления или Parcelables.

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

Ключ должен поддерживаться Bundle , чтобы rememberSaveable внутри составного элемента можно было восстановить при воссоздании действия или даже при прокрутке от этого элемента и прокрутке назад.

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

Анимация предметов

Если вы использовали виджет RecyclerView, вы знаете, что он автоматически анимирует изменения элементов . Ленивые макеты предоставляют ту же функциональность для изменения порядка элементов. 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 предоставляет свойства firstVisibleItemIndex и firstVisibleItemScrollOffset .

Если мы используем пример отображения и скрытия кнопки в зависимости от того, прокрутил ли пользователь первый элемент:

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

Чтение состояния непосредственно в композиции полезно, когда вам нужно обновить другие составные элементы пользовательского интерфейса, но существуют также сценарии, в которых событие не требуется обрабатывать в той же композиции. Типичным примером этого является отправка аналитического события после того, как пользователь прокрутил страницу до определенной точки. Чтобы эффективно справиться с этим, мы можем использовать 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 и более поздние версии обеспечивают поддержку Compose через библиотеку androidx.paging:paging-compose .

Чтобы отобразить список постраничного контента, мы можем использовать функцию расширения collectAsLazyPagingItems() , а затем передать возвращенные LazyPagingItems в items() в нашем LazyColumn . Подобно поддержке разбиения на страницы в представлениях, вы можете отображать заполнители во время загрузки данных, проверяя, имеет ли 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()
            }
        }
    }
}

Советы по использованию ленивых макетов

Есть несколько советов, которые вы можете принять во внимание, чтобы ваши ленивые макеты работали должным образом.

Избегайте использования элементов размером 0 пикселей.

Это может произойти в сценариях, где, например, вы ожидаете асинхронно получить некоторые данные, например изображения, для заполнения элементов вашего списка на более позднем этапе. Это приведет к тому, что ленивый макет скомпонует все свои элементы при первом измерении, поскольку их высота равна 0 пикселей, и они все могут поместиться в области просмотра. После загрузки элементов и увеличения их высоты ленивые макеты отбрасывают все остальные элементы, которые были без необходимости составлены в первый раз, поскольку они фактически не могут поместиться в область просмотра. Чтобы избежать этого, вам следует установить для ваших элементов размеры по умолчанию, чтобы ленивый макет мог правильно рассчитать, сколько элементов фактически может поместиться в области просмотра:

@Composable
fun Item(imageUrl: String) {
    AsyncImage(
        model = rememberAsyncImagePainter(model = imageUrl),
        modifier = Modifier.size(30.dp),
        contentDescription = null
        // ...
    )
}

Если вы знаете приблизительный размер ваших элементов после асинхронной загрузки данных, рекомендуется обеспечить одинаковый размер ваших элементов до и после загрузки, например, добавив несколько заполнителей. Это поможет сохранить правильное положение прокрутки.

Избегайте вложения компонентов, прокручиваемых в одном направлении.

Это применимо только к случаям вложения прокручиваемых дочерних элементов без предопределенного размера внутри другого прокручиваемого родительского элемента в том же направлении. Например, попытка вложить дочерний LazyColumn без фиксированной высоты в родительский Column с вертикальной прокруткой:

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

Остерегайтесь размещения нескольких элементов в одном элементе.

В этом примере вторая лямбда элемента генерирует 2 элемента в одном блоке:

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

Ленивые макеты справятся с этим, как и ожидалось: они будут располагать элементы один за другим, как если бы это были разные элементы. Однако при этом есть несколько проблем.

Когда несколько элементов создаются как часть одного элемента, они обрабатываются как один объект, а это означает, что их больше нельзя составлять по отдельности. Если на экране становится видимым один элемент, то необходимо составить и измерить все элементы, соответствующие этому элементу. Это может снизить производительность при чрезмерном использовании. В крайнем случае размещение всех элементов в одном элементе полностью лишает смысла использование ленивых макетов. Помимо потенциальных проблем с производительностью, размещение большего количества элементов в одном элементе также будет мешать работе scrollToItem() и animateScrollToItem() .

Однако существуют допустимые варианты использования нескольких элементов в одном элементе, например наличие разделителей внутри списка. Вы не хотите, чтобы разделители меняли индексы прокрутки, поскольку их не следует считать независимыми элементами. Кроме того, производительность не пострадает, поскольку разделители небольшие. Разделитель, вероятно, должен быть видимым, когда элемент перед ним виден, чтобы он мог быть частью предыдущего элемента:

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

Рассмотрите возможность использования индивидуальных аранжировок

Обычно в ленивых списках много элементов, и они занимают размер, превышающий размер контейнера прокрутки. Однако, когда ваш список заполнен небольшим количеством элементов, ваш дизайн может иметь более конкретные требования к тому, как они должны располагаться в области просмотра.

Для этого вы можете использовать собственное вертикальное 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. Это помогает максимизировать преимущества повторного использования композиции и производительность вашего ленивого макета.

Измерение производительности

Надежно измерить производительность ленивого макета можно только при работе в режиме выпуска и с включенной оптимизацией R8. В отладочных сборках ленивая прокрутка макета может работать медленнее. Для получения дополнительной информации об этом прочитайте Compose Performance .

{% дословно %} {% дословно %} {% дословно %} {% дословно %}