Прямые ссылки на публичные уроки для быстрого старта и стабильной индексации lesson-страниц.
Продолжаем разрабатывать экраны гипотетического образовательного приложения. Сейчас реализуем экран со списком уроков.
Расскажу как правильно реализовывать списки в Jetpack Compose — быстро, красиво и без ущерба для пользовательского опыта. В ближайшем будущем это будет список текстовых элементов, который превратится в набор кликабельных карточек. В следующих уроках при клике на элемент будем осуществлять переход на другой экран.
Заготовка — колонка с заголовками и кнопкой. LessonsListScreen вызывается в MainActivity в блоке с темой. Из нового — реализованный topBar для отображения кнопки навигации в левом верхнем углу. Используются два параметра Scaffold: topBar и content. Внутри topBar вызывается TopAppBar с обязательным параметром заголовка и иконкой навигации.
@OptIn(ExperimentalMaterial3Api::class) @Composable fun LessonsListScreen() { Scaffold( topBar = { TopAppBar( title = { Text("Назад") }, navigationIcon = { IconButton(onClick = { }) { Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft, contentDescription = "Back Button", ) } } ) }, content = { paddingValues -> Column( modifier = Modifier .fillMaxSize() .padding(paddingValues) ) { Column( modifier = Modifier .fillMaxWidth() .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = "Бесплатный курс", style = MaterialTheme.typography.headlineSmall ) Spacer(modifier = Modifier.height(8.dp)) Text( text = "Kotlin для начинающих", style = MaterialTheme.typography.headlineLarge ) Spacer(modifier = Modifier.height(16.dp)) Button( shape = RoundedCornerShape(13.dp), onClick = {}, modifier = Modifier .height(56.dp) .padding(horizontal = 40.dp) .fillMaxWidth() ) { Text( text = "Начать", style = MaterialTheme.typography.labelMedium ) } } Spacer(modifier = Modifier.height(50.dp)) Text( text = "Содержание курса", style = MaterialTheme.typography.headlineMedium.copy( fontSize = 18.sp, fontFamily = FontFamily(Font(R.font.gilroy_semibold)), ), modifier = Modifier.padding(horizontal = 16.dp) ) Spacer(modifier = Modifier.height(16.dp)) } } ) }
Список уроков предполагает порядковые номера, поэтому начнем с вывода простого списка цифр по возрастанию. На Jetpack Compose списки реализуются в несколько строк — в отличие от трудоемкого RecyclerView в XML.
Добавим отдельную колонку для списка. Чтобы отобразить один и тот же текст N раз, воспользуемся Kotlin-функцией repeat — или любым другим циклом. Сделаем 50 итераций, в качестве значения используем индекс плюс единица.
Column { repeat(50) { index -> Text( text = "${index + 1}", modifier = Modifier .padding(start = 20.dp) .fillMaxWidth(), fontSize = 24.sp ) } }
Такая реализация имеет ряд очевидных проблем:
Column по умолчанию этим свойством не обладает. Решается добавлением модификатора verticalScroll, который принимает обязательный параметр ScrollState. Стейт нужен, чтобы состояние скролла не сбрасывалось при рекомпозиции — создаем его.val scrollState = rememberScrollState() Column( modifier = Modifier.verticalScroll(scrollState) ) { repeat(50000) { index -> Text( text = "${index + 1}", modifier = Modifier .padding(start = 20.dp) .fillMaxWidth(), fontSize = 24.sp ) }
OutOfMemoryError.Создавать все объекты списка сразу — плохая практика. Колонку со скроллом можно использовать только при заведомо небольшом и фиксированном количестве элементов.
Для всех остальных случаев в XML был придуман RecyclerView, который с помощью адаптера генерировал только видимые элементы. На экране отображаются только те элементы, которые видны пользователю. Когда верхний элемент при прокрутке уходит за пределы экрана, его содержимое очищается, а сам элемент перемещается вниз и заполняется новыми данными — переиспользуется. Отсюда и название: Recycle — повторное использование. Даже для 50.000 элементов в памяти одновременно создается лишь то количество, которое помещается на экране.
Аналогичный компонент есть и в Jetpack Compose, только реализуется значительно проще — это LazyColumn.
Чтобы его внедрить, заменяем Column вместе с модификатором на LazyColumn. Переменная со стейтом скролла больше не нужна.
Однако сразу получаем ошибку: вызовы @Composable-функций могут происходить только из контекста другой @Composable-функции. Если Text — это системная composable-функция, то что не так?
Провалимся в объявление LazyColumn — последний параметр функции имеет тип LazyListScope. Вспомним материал из урока про DSL: скоуп — это "комната", где разрешено размещение только определенных элементов.
Row) — простой контейнер, который сразу создаёт и раскладывает все дочерние Composable. ColumnScope позволяет размещать любые composable-элементы.LazyRow) — "ленивый" список. Чтобы понимать, сколько элементов, какие у них ключи и как их доставать по мере скролла, создан специальный DSL. LazyListScope содержит ограниченный набор функций: item { ... }, items(...), stickyHeader(...).Тип скоупа ленивого списка не является composable — поэтому Text напрямую внутри разместить нельзя. Если бы разработчики разрешили произвольно вызывать Text(), Box(), Button() без обёртки в itemitems, система не знала бы, когда и как эти элементы пересоздавать при прокрутке — терялась бы вся суть ленивой оптимизации.
Можно использовать функцию item и поместить в неё текст — она предназначена для отображения одного элемента. Если обернуть её в repeat, LazyColumn будет создавать только необходимое количество видимых элементов. Но код можно сделать короче.
Чаще всего используют функцию items. Первый обязательный параметр — количество элементов. Индекс достаётся из лямбды по аналогии с repeat.
LazyColumn { items(50000) { index -> Text( text = "${index + 1}", modifier = Modifier .padding(start = 20.dp) .fillMaxWidth(), fontSize = 24.sp ) } }
LazyColumn создаёт и отображает только видимые элементы. Даже с 50.000 объектов интерфейс работает стабильно — OutOfMemoryError больше не возникает.
StickyHeader — фиксируемый заголовок, который остаётся в зоне видимости, пока его секция не скроется. Отлично подходит для разделения списка на логические группы.
LazyColumn { stickyHeader { Text( text = "Заголовок раздела", modifier = Modifier .fillMaxWidth() .background(Color.LightGray) .padding(8.dp), ) } items(50) { index -> Text( text = "${index + 1}", modifier = Modifier .padding(start = 20.dp) .fillMaxWidth(), fontSize = 24.sp ) } }
stickyHeader можно дублировать — каждый будет фиксироваться над своей секцией, демонстрируя разные группы со своими подзаголовками.
itemsIndexed позволяет одновременно работать с индексами и содержимым списка — полезно, когда нужен и порядковый номер, и сам объект.
val lessons = listOf("Урок 1", "Урок 2", "Урок 3") LazyColumn { itemsIndexed(lessons) { index, lesson -> Text( text = "${index + 1}. $lesson", modifier = Modifier .padding(start = 20.dp) .fillMaxWidth(), fontSize = 24.sp ) } }
Вместо статичных элементов используем динамические данные. Список может отображать любое содержимое — не только примитивы, но и наборы сложных объектов.
Мы разобрались с тем, как работать со списками в Jetpack Compose: от статичной Column до LazyColumn с ленивой загрузкой. Это позволяет создавать эффективные списки даже с огромным количеством элементов.
Карточки уроков будут сверстаны за кадром — на них будем тренироваться в реализации навигации в следующих уроках.
В следующем уроке разберемся, как заставить наше приложение переключаться между экранами.