Перейти к основному содержимому
Мобилка16ГЛАВА 16Списки: LazyColumn и всё, что не нарисовать цикломтолько видимое

Списки: LazyColumn и всё, что не нарисовать циклом

Прочитано0%
Квизы0/5
Тренажёрыне пройден0/1
Уровень 1 · Новичок

Зачем это нужно​

Посмотрите на любое конкурсное задание. Каталог товаров. Список проектов. Корзина. Экран уведомлений. История заказов. Почти каждый экран — это список, и почти каждый список длиннее, чем помещается в экран.

В прошлой главе мы собрали карточку товара и вынесли её в UI Kit. Один товар. Теперь их две сотни, и первое, что приходит в голову, — обернуть карточку в цикл внутри Column. Это работает ровно до того момента, пока список короткий: на длинном приложение начинает открываться заметно медленнее, а на очень длинном падает совсем.

Причина не в скорости отрисовки, а в количестве. Column строит все элементы, которые в нём перечислены, — независимо от того, видит их пользователь или нет. Двести карточек — двести построенных карточек, из которых на экране семь.

Эта глава про инструмент, который строит только видимое: LazyColumn. Он же — единственный правильный способ показать список в Compose, и на чемпионате им собирается большинство экранов модуля вёрстки.

Почему Column не тянет длинный список​

Смоделируем происходящее на чистом Kotlin, без Compose. Класс Card считает, сколько раз его создали. Первый способ строит все объекты сразу — так работает Column. Второй берёт только первые десять — так работает LazyColumn.

class Card(val text: String) { init { created++ } companion object { var created = 0 } } fun main() { val data = List(10_000) { "товар $it" } Card.created = 0 val all = data.map { Card(it) } println("Column построил объектов: ${Card.created}") Card.created = 0 val visible = data.asSequence() .map { Card(it) } .take(10) .toList() println("LazyColumn построил объектов: ${Card.created}") println("а на экран помещается: ${visible.size}") }

Живой вывод:

Column построил объектов: 10000
LazyColumn построил объектов: 10
а на экран помещается: 10

Разница в тысячу раз, и она не про «оптимизацию» — она про то, существуют объекты вообще или нет. В настоящем приложении каждая такая карточка тянет за собой измерение размеров, расположение и подготовку к отрисовке. Десять тысяч раз до первого кадра — это секунды пустого ожидания на старте экрана.

ДЕСЯТЬ ТЫСЯЧ ТОВАРОВ, ДВА СПОСОБАColumn — строит всё сразувсе 10 000 объектов созданы до первого кадраLazyColumn — строит видимоеэкраностальные 9 990 ещё не существуют — появятся при прокруткеразница не в скорости отрисовки, а в том, сколько объектов вообще создано
Column создаёт все элементы до первого кадра; LazyColumn — только видимые с небольшим запасом, остальные появляются при прокрутке
Важно

Column со списком внутри — не ошибка сама по себе. Для пяти пунктов меню или трёх строк в карточке он уместнее и проще. Правило простое: если количество элементов зависит от данных с сервера — берите LazyColumn. Вы не знаете заранее, вернёт сервер три товара или три тысячи.

Первый ленивый список​

Выглядит он почти как Column, но внутри фигурных скобок пишут иначе:

@Composable
fun ProductList(products: List<Product>) {
LazyColumn {
items(products) { product ->
ProductCard(
title = product.title,
price = product.price,
)
}
}
}
Нажми на часть выражения

Прокрутка при этом появляется сама: LazyColumn прокручивается по умолчанию, никакого verticalScroll добавлять не нужно — и, как увидим дальше, добавлять его прямо запрещено.

item и items — это не обычный код​

Внутри фигурных скобок LazyColumn работает не обычная функция, а область списка со своими правилами. Смоделируем её на чистом Kotlin, чтобы стало видно, что там происходит:

class ListScope { fun item(render: () -> Unit) { println(" зарегистрирован 1 элемент"); render() } fun <T> items(list: List<T>, render: (T) -> Unit) { println(" зарегистрировано элементов: ${list.size}") list.forEach(render) } } fun lazyColumn(content: ListScope.() -> Unit) { println("собираю список:") ListScope().content() } fun main() { lazyColumn { item { println(" шапка") } items(listOf("хлеб", "молоко")) { println(" товар: $it") } } }
собираю список:
зарегистрирован 1 элемент
шапка
зарегистрировано элементов: 2
товар: хлеб
товар: молоко

Обратите внимание на тип параметра: ListScope.() -> Unit. Это лямбда с приёмником — та самая конструкция, которая делает возможными все DSL в Kotlin, включая сам Compose. Благодаря ей внутри блока доступны item и items, которых снаружи нет.

Отсюда главное практическое следствие: обычный for внутри LazyColumn не работает так, как вы ожидаете. Он не зарегистрирует элементы — он попытается сразу построить их все, и вы вернётесь к проблеме Column, только запутаннее.

ВНУТРИ ФИГУРНЫХ СКОБОК — НЕ ОБЫЧНЫЙ КОДLazyColumn {область спискаitem { Header() }регистрирует 1 элементitems(products) { ... }регистрирует N элементовэто описание того, что в списке есть — рисуется потом и только видимоепоэтому обычный for внутри работать не будет: он ничего не регистрирует
item и items не рисуют, а регистрируют: сколько элементов в списке и как рисовать каждый
Что писатьКогда
item { }один элемент: шапка, подвал, кнопка «Показать ещё»
items(list) { }элементы по списку данных
itemsIndexed(list) { i, x -> }когда нужен номер: «1. Молоко», нумерация в рейтинге
items(count = 20) { }фиксированное количество, например скелетоны при загрузке
Отвечено вопросов: 0 из 2
Почему внутри LazyColumn не пишут обычный цикл for?
Что означает тип content: ListScope.() -> Unit?
Отвечено верно: 0 из 2

Отступы и промежутки​

Первое, что обнаруживается при вёрстке по макету: между карточками нужен зазор, а у всего списка — поля по краям. Наивное решение — повесить padding на каждый элемент — даёт неверную картину: сверху и снизу списка появляется лишний отступ, а при прокрутке элементы упираются в край.

Правильных инструментов два, и они разные:

LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(products) { ProductCard(it) }
}

contentPadding — поля вокруг всего содержимого. Важно, что это отступ содержимого, а не самого списка: при прокрутке элементы уезжают под него, а не обрезаются. Именно так делают отступ под шапкой и над нижней панелью.

Arrangement.spacedBy — промежуток между элементами. Ключевое слово «между»: перед первым и после последнего его не будет, что как раз и нужно по макету.

Совет

На чемпионате отступы проверяют измерением, а не на глаз: «фактические пиксельные отступы на скриншоте должны соответствовать математической пропорции относительно макета». Поэтому spacedBy(12.dp) вместо padding(6.dp) на каждом элементе — это не вкусовщина, а разница между «12 между карточками» и «12 между карточками, но 6 по краям экрана».

key: зачем списку имена​

Это самая частая и самая незаметная ошибка в списках. Незаметная — потому что без неё всё выглядит рабочим, пока список не начнёт меняться.

По умолчанию Compose опознаёт элемент списка по его позиции: нулевой, первый, второй. Пока список неподвижен, разницы нет. Но стоит удалить элемент — и позиции сдвигаются, а всё, что было привязано к позиции, остаётся на месте. Смоделируем:

data class Row(val id: Int, val title: String, var checked: Boolean = false) fun main() { val rows = mutableListOf(Row(1, "молоко"), Row(2, "хлеб"), Row(3, "сыр")) rows[2].checked = true println("до удаления:") rows.forEachIndexed { i, r -> println(" позиция $i -> ${r.title}, отмечен=${r.checked}") } rows.removeAt(0) println("после удаления первого — состояние привязано к позиции:") rows.forEachIndexed { i, r -> println(" позиция $i -> ${r.title}") } println("позиция 2 теперь пуста, а отметка была на ней") println("если привязать состояние к id=${rows.last().id}, оно останется на своём сыре") }
до удаления:
позиция 0 -> молоко, отмечен=false
позиция 1 -> хлеб, отмечен=false
позиция 2 -> сыр, отмечен=true
после удаления первого — состояние привязано к позиции:
позиция 0 -> хлеб
позиция 1 -> сыр
позиция 2 теперь пуста, а отметка была на ней
если привязать состояние к id=3, оно останется на своём сыре

Лечится одним параметром:

items(products, key = { it.id }) { product ->
ProductCard(product)
}

Теперь Compose опознаёт элемент по id из данных, а не по номеру места. Состояние — раскрытая карточка, введённый текст, положение переключателя — едет вместе со своим элементом.

УДАЛИЛИ ПЕРВЫЙ — ЧТО СТАЛО С ОТМЕТКОЙбыло0 · молоко1 · хлеб2 · сыр✓удалили 0без key — отметка осталась на позиции0 · хлеб1 · сыр2 · пусто, а ✓ была тутс key — отметка едет за сыромid=2 · хлебid=3 · сыр✓без key элемент опознаётся по номеру места, а место при удалении занимает соседkey = id привязывает состояние к самим данным, а не к позиции в спискето же касается анимации перестановки: без key список «моргает» вместо переезда
Без key состояние привязано к позиции и остаётся на месте при удалении; с key оно следует за своим элементом

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

Отвечено вопросов: 0 из 2
Что произойдёт при удалении первого элемента списка без key?
Что подходит на роль key?
Отвечено верно: 0 из 2

Горизонтальные списки и сетки​

Тот же механизм работает в других направлениях. Синтаксис внутри — идентичный, меняется только контейнер:

LazyRow { items(categories) { CategoryChip(it) } } // подборка в строку

LazyVerticalGrid(columns = GridCells.Fixed(2)) { // сетка в две колонки
items(products) { ProductCard(it) }
}

LazyVerticalGrid(columns = GridCells.Adaptive(minSize = 140.dp)) { // колонок столько, сколько влезет
items(products) { ProductCard(it) }
}

GridCells.Fixed(2) — ровно две колонки на любом экране. GridCells.Adaptive(minSize) — колонок столько, сколько поместится при заданной минимальной ширине: на телефоне две, на планшете четыре. Второй вариант чаще выигрывает, когда работу открывают на незнакомом устройстве.

Состояние списка: где мы сейчас​

Иногда нужно знать положение прокрутки или управлять им: показать кнопку «наверх», прокрутить к найденному товару, догрузить страницу при подходе к концу.

val listState = rememberLazyListState()

LazyColumn(state = listState) {
items(products, key = { it.id }) { ProductCard(it) }
}

// прокрутить к началу — из корутины, потому что это анимация
scope.launch { listState.animateScrollToItem(0) }

rememberLazyListState() переживает рекомпозицию, как и всякий remember из главы про состояние. Прокрутка при этом не сбрасывается при обновлении данных — при условии, что у элементов есть key.

Запрещённое вложение​

Одна ошибка заслуживает отдельной секции, потому что её сообщение непонятно с первого раза:

Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
Text("Заголовок")
LazyColumn { items(products) { ProductCard(it) } } // так нельзя
}

Приложение падает с исключением про бесконечную высоту. Причина логична, если проговорить: прокручиваемый Column сообщает детям, что высота не ограничена — «расти сколько хочешь». А LazyColumn строит только видимое, для чего ему нужно знать, сколько именно видно. Два требования исключают друг друга.

Правильное решение — не вкладывать, а объединять. Всё содержимое экрана становится элементами одного списка:

LazyColumn {
item { Text("Заголовок") }
item { Banner() }
items(products, key = { it.id }) { ProductCard(it) }
item { Footer() }
}

Это не обходной приём, а то, как экраны в Compose собирают правильно: один список, в котором шапка и подвал — такие же элементы, как карточки.

Типичные ошибки​

LazyColumn внутри verticalScroll. Падение с сообщением про бесконечные ограничения по высоте. Лечится объединением в один список, как выше.

Забыли key. Ошибки нет. Состояние элементов съезжает при удалении и перестановке, анимация вместо переезда даёт мигание. Замечается поздно и выглядит как «мистика».

Повторяющийся key. IllegalArgumentException про дублирующийся ключ. Обычно это попытка использовать название вместо идентификатора.

padding на каждом элементе вместо spacedBy. Отступы по краям списка удваиваются, макет не сходится при измерении.

Тяжёлые вычисления внутри items { }. Форматирование цены, разбор даты, фильтрация — всё это выполняется при каждой отрисовке каждого элемента при каждой прокрутке. Готовьте данные заранее, в ViewModel, а в списке только показывайте.

Modifier.verticalScroll или fillMaxSize() внутри элемента списка. Падение с сообщением про бесконечную высоту — по той же причине, что и вложенный LazyColumn: элемент просит неограниченную высоту у списка, который сам её не знает. В элементе нужен fillMaxWidth(), а высота — по содержимому.

Слова главы​

  • Ленивый список — строит только видимые элементы плюс небольшой запас.
  • LazyColumn / LazyRow — вертикальный и горизонтальный ленивые списки.
  • LazyVerticalGrid — ленивая сетка; GridCells.Fixed(n) или GridCells.Adaptive(minSize).
  • item { } — зарегистрировать один элемент.
  • items(list) { } — зарегистрировать элементы по списку данных.
  • key — постоянный идентификатор элемента; привязывает состояние к данным, а не к позиции.
  • contentPadding — поля вокруг содержимого списка; при прокрутке содержимое уезжает под них.
  • Arrangement.spacedBy — промежуток между элементами, без краевых отступов.
  • rememberLazyListState — состояние прокрутки: где мы сейчас и куда прокрутить.

Тренажёр: набрать вслепую​

Эти строки на чемпионате набираются на каждом экране со списком, а привозить заготовки нельзя.

Цель: скорость ≥ 100 зн/мин, точность ≥ 88%

LazyColumn { items(products, key = { it.id }) { ProductCard(it) } }

На чемпионате​

Модуль вёрстки — самый дорогой в конкурсном задании, и списки в нём встречаются почти на каждом экране: каталог, корзина, список проектов, уведомления, история заказов.

Что проверяют по факту:

  • Список прокручивается и не тормозит на полном наборе данных, а не на трёх тестовых элементах.
  • Отступы сходятся с макетом при измерении — здесь и нужны contentPadding со spacedBy.
  • Поиск и фильтрация поверх списка: показывается отфильтрованное, состояние не съезжает — то есть key обязателен.
  • Пагинация: в заданиях 2026 года встречается загрузка по 50 элементов. Момент догрузки определяют по listState.
  • Пустой список — отдельный экран по макету, а не пустое место. Проверяйте if (products.isEmpty()) до LazyColumn.
Отвечено вопросов: 0 из 2
Экран: заголовок, баннер, список товаров и подвал. Как собрать?
Список из 500 товаров с поиском. Что обязательно?
Отвечено верно: 0 из 2

Что дальше​

Список умеет показывать данные, но данные пока приходят из захардкоженного набора. Дальше в треке они появятся из сети — и именно список станет первым, что увидит настоящий ответ сервера. Тогда же понадобится то, что разберём в главе про Flow: список, который обновляется сам, когда данные пришли или изменились в базе.

Отвечено вопросов: 0 из 1
Когда Column со списком внутри — нормальное решение?
Отвечено верно: 0 из 1

Углубиться​

  • stickyHeader — заголовок раздела, прилипающий к верху при прокрутке: список контактов по буквам, заказы по датам.
  • animateItem() — плавная перестановка элементов при изменении списка; работает только вместе с key.
  • LazyListState.layoutInfo — какие элементы сейчас видны; на этом строят догрузку страниц и аналитику показов.
  • contentType в items — подсказка о типе элемента, помогает переиспользованию, когда в одном списке разные по устройству карточки.
  • Библиотека Paging — постраничная загрузка со всей обвязкой: индикаторы, повтор при ошибке, объединение с базой.

Сводная проверка по всей главе: 7 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.

Куда дальше: проверенные ресурсы​

Видео по теме

ВидеоLazy layouts in ComposeAndroid Developers
ВидеоLists - Android Jetpack Compose - Part 8Philipp Lackner
ВидеоLazy Lists In Jetpack Compose - (Lazy Column, Row, Grid)Ahmed Guedmioui
ВидеоTop 3 Hacks to Remove LazyColumn Lag in Jetpack ComposePhilipp Lackner
ВидеоThe FULL Nested Scrolling Guide for Jetpack ComposePhilipp Lackner

Комментарии

Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.