LazyColumn — komponent Jetpack Compose do wyświetlania przewijanych list z leniwym ładowaniem. W przeciwieństwie do RecyclerView, LazyColumn nie tworzy ViewHolderów i nie używa XML — wszystkie elementy opisane są deklaratywnie przez funkcje composable. Pokazujemy, czym jest LazyColumn, jak działa LazyListState i items API, oraz jak osiągnąć wydajność na poziomie 60 FPS. W IT Sectr używamy LazyColumn we wszystkich nowych projektach Compose. Dla porównania z klasycznym podejściem przeczytaj artykuł o RecyclerView.
Najważniejsze
items(), itemsIndexed(), items() z kluczami do wiązania danych.LazyColumn — to funkcja composable z biblioteki Foundation Compose, która wyświetla pionową listę elementów z leniwym (lazy) ładowaniem: tworzone i komponowane są tylko te elementy, które są widoczne na ekranie lub znajdują się w pobliżu (strefa prefetch). LazyColumn pojawił się w Compose 1.0 (2021) wraz z LazyRow (lista pozioma) i LazyVerticalGrid (siatka).
Leniwę ładowanie działa poprzez mechanizm SubcomposeLayout: LazyColumn mierzy dostępne miejsce, żąda z LayoutInfo widocznego zakresu i komponuje tylko elementy w tym zakresie. Elementy, które zniknęły z ekranu, opuszczają kompozycję (z wyjątkiem bufora prefetch). Według danych Google (Android Performance, 2026), LazyColumn zapewnia 60 FPS podczas przewijania listy 10000+ elementów na urządzeniach średniej klasy.
Column z pionowym przewijaniem — to zwykłe ułożenie wszystkich elementów jeden pod drugim z zastosowaniem modyfikatora verticalScroll. Column nie jest leniwy: wszystkie elementy potomne są komponowane od razu, nawet jeśli nie są widoczne. Dla listy 200+ elementów Column powoduje znaczne opóźnienie przy starcie. LazyColumn komponuje tylko widoczne + bufor (domyślnie 1 ekran w przód/tył), co czyni go niezastąpionym dla długich list.
items API — zestaw funkcji rozszerzających dla LazyColumn, które przyjmują listę danych i opis DSL dla każdego elementu. Główne funkcje: items(count, key, itemContent) dla stałej liczby, items(list, key, itemContent) dla listy, itemsIndexed(list, key, itemContent) z indeksem. Parametr key — obowiązkowy warunek wydajności — daje Compose stabilny identyfikator elementu.
// Podstawowa LazyColumn z itemContent
@Composable
fun ArticleList(articles: List<Article>) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Header — nie leniwy, zawsze na początku
item {
Text("Ostatnie artykuły", style = MaterialTheme.typography.headlineMedium)
}
// Lista artykułów z kluczem po id
items(articles, key = { it.id }) { article ->
ArticleCard(
title = article.title,
summary = article.summary,
onClick = { onArticleClick(article.id) }
)
}
// Footer ze wskaźnikiem ładowania
item {
CircularProgressIndicator(
modifier = Modifier.fillMaxWidth().padding(16.dp)
)
}
}
}
// Niestandardowy element listy
@Composable
fun ArticleCard(title: String, summary: String, onClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(4.dp))
Text(text = summary, style = MaterialTheme.typography.bodyMedium)
}
}
}
item {} vs items(): używaj item {} dla pojedynczych elementów, które zawsze występują na liście (header, footer, separatory). Używaj items() dla dynamicznej listy danych. items() przyjmuje Iterable lub Int i blok DSL, który jest wywoływany dla każdego elementu. Łącz item i items w jednym LazyColumn: kolejność wywołań określa kolejność wyświetlania.
LazyListState — obiekt przechowujący stan listy: bieżącą pozycję przewijania (firstVisibleItemIndex, firstVisibleItemScrollOffset), widoczne elementy (layoutInfo) i metody programowego przewijania (scrollToItem, animateScrollToItem). LazyListState tworzy się przez rememberLazyListState() i przekazuje do LazyColumn przez parametr state.
// LazyColumn z zachowaniem pozycji przewijania
@Composable
fun ScrollingList(items: List<Item>) {
val listState = rememberLazyListState()
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(state = listState) {
items(items, key = { it.id }) { item ->
ListItemView(item = item)
}
}
// Przycisk "Do góry" pojawia się po 10. elemencie
val showButton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 10 }
}
AnimatedVisibility(visible = showButton) {
FloatingActionButton(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp),
onClick = {
coroutineScope.launch {
listState.animateScrollToItem(0)
}
}
) {
Icon(Icons.Default.KeyboardArrowUp, "Na górę")
}
}
}
}
// Pobieranie informacji o widocznych elementach
@Composable
fun ListDebugInfo(listState: LazyListState) {
val visibleItems = listState.layoutInfo.visibleItemsInfo
val totalItems = listState.layoutInfo.totalItemsCount
val firstItem = visibleItems.firstOrNull()?.index
val lastItem = visibleItems.lastOrNull()?.index
Text("Wyświetlono $firstItem–$lastItem z $totalItems")
}
Programowe przewijanie wykonuje się przez suspend-funkcje listState: scrollToItem(index, scrollOffset) — natychmiastowe przesunięcie (bez animacji), animateScrollToItem(index) — z animacją. Do przewijania z korutyny używaj LaunchedEffect lub coroutineScope.launch. Ważne: nie wywołuj suspend-funkcji z kompozycji (wewnątrz @Composable) — używaj lambd (onClick, LaunchedEffect).
LazyColumn i RecyclerView rozwiązują to samo zadanie — efektywne wyświetlanie długich list. Różnica w architekturze: RecyclerView używa ViewHolder i Adapter (klasyczny Android View), LazyColumn — deklaratywne funkcje composable bez XML i ViewHolder. Wybór zależy od stosu technologicznego projektu: Compose vs View-based UI.
| Parametr | LazyColumn (Compose) | RecyclerView (View) |
|---|---|---|
| Układ | Funkcje Composable (Kotlin DSL) | Pliki XML + ViewBinding |
| Adapter | items() / itemsIndexed() DSL | RecyclerView.Adapter + ViewHolder |
| Sortowanie/filtrowanie | Snapshot state + derivedStateOf | DiffUtil + AsyncListDiffer |
| Animacja | AnimatedVisibility + Modifier.animateItem | DefaultItemAnimator |
| Różne typy elementów | item {}/items() + when po typie | getItemViewType + wiele ViewHolderów |
| Przewijanie do pozycji | listState.animateScrollToItem() | layoutManager.scrollToPosition() |
| Prefetch | In-process (SubcomposeLayout buffer) | RecycledViewPool + GapWorker |
| Sieć/paginacja | collectAsLazyPagingItems() + Paging 3 | PagingDataAdapter + Paging 3 |
Zalecenie Google (Android Developers, 2026): dla nowych projektów na Jetpack Compose używaj LazyColumn. Dla projektów View-based używaj RecyclerView. LazyColumn wymaga Compose, który dodaje do APK około 3–5 MB — uwzględnij to przy wsparciu starych urządzeń. Jeśli projekt już używa systemu View, LazyColumn nie może współistnieć w jednym ekranie z układem XML bez AndroidView — w takich przypadkach łatwiej pozostać na RecyclerView.
LazyVerticalGrid — wersja LazyColumn dla siatek o stałej liczbie kolumn (columns). LazyHorizontalGrid — pozioma siatka o stałej liczbie wierszy (rows). Oba komponenty używają tych samych zasad leniwego ładowania i tego samego items API co LazyColumn. Parametr columns określa liczbę kolumn przez GridCells.Fixed(N) lub GridCells.Adaptive(minSize).
// Siatka z adaptacyjnymi kolumnami
@Composable
fun PhotoGrid(photos: List<Photo>) {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 128.dp),
contentPadding = PaddingValues(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(photos, key = { it.id }) { photo ->
AsyncImage(
model = photo.url,
contentDescription = photo.title,
modifier = Modifier.aspectRatio(1f)
)
}
}
}
// Lista pozioma (LazyRow)
@Composable
fun CategoryCarousel(categories: List<Category>) {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
items(categories, key = { it.id }) { category ->
FilterChip(
selected = category.isSelected,
onClick = { onCategoryClick(category.id) },
label = { Text(category.name) }
)
}
}
}
GridCells.Adaptive automatycznie oblicza liczbę kolumn na podstawie minimalnego rozmiaru komórki. Na przykład GridCells.Adaptive(128.dp) na ekranie o szerokości 360 dp mieści 2 kolumny, na tablecie 600 dp — 4 kolumny. GridCells.Fixed(2) zawsze pokazuje dokładnie 2 kolumny. Adaptive jest preferowany do adaptacji wieloekranowej — automatycznie dostosowuje się do szerokości ekranu bez zapytań medialnych.
Wydajność LazyColumn zależy od trzech czynników: stabilność kluczy (key), typ zawartości (contentType) i bufor prefetch (beyondBounds). Domyślnie LazyColumn buforuje 1 ekran w przód i 1 w tył. Google (Android Developers, 2026) zaleca konfigurowanie tych parametrów dla list z różnymi typami elementów lub złożoną kompozycją.
| Parametr | Opis | Zalecenie |
|---|---|---|
| key | Stabilny identyfikator elementu | Zawsze podawaj key = { it.id }. Bez key Compose używa indeksu, a zmiana kolejności elementów psuje animację. |
| contentType | Typ zawartości do podziału pul | Określ dla list z różnymi typami elementów (tekst + obraz + reklama). Compose będzie ponownie używać kompozycji tylko w ramach jednego contentType. |
| beyondBounds | Liczba ekranów do prefetch | Zwiększ do 2–3 dla list z ciężkimi obrazami. Domyślnie 1 ekran. |
| Modifier.animateItem | Animacja zmiany pozycji | Używaj do animowanego sortowania i filtrowania (Compose 1.7+). |
// Zoptymalizowana LazyColumn z contentType i prefetch
@Composable
fun OptimizedFeed(items: List<FeedItem>) {
val listState = rememberLazyListState()
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
beyondBoundsPageCount = 2 // prefetch 2 ekrany
) {
items(
items = items,
key = { it.id },
contentType = { it.type } // podział według typów
) { item ->
when (item) {
is FeedItem.Post -> PostView(item)
is FeedItem.Ad -> AdView(item)
is FeedItem.Suggested -> SuggestedView(item)
}
}
}
}
// Pomiar wydajności przez listState
@Composable
fun ScrollPerformance(listState: LazyListState) {
val scrollInfo = listState.layoutInfo
val visibleCount = scrollInfo.visibleItemsInfo.size
val total = scrollInfo.totalItemsCount
// derivedStateOf — nie przerzuca się, jeśli wartość się nie zmieniła
val scrollProgress by remember {
derivedStateOf {
if (total > 0) visibleCount.toFloat() / total else 0f
}
}
LinearProgressIndicator(
progress = scrollProgress,
modifier = Modifier.fillMaxWidth().height(2.dp)
)
}
Częste błędy: (1) brak key — elementy są ponownie komponowane przy każdej zmianie listy; (2) używanie mutableStateListOf bez strumieni snapshot — zmiany mogą nie być śledzone; (3) wywoływanie suspend-funkcji wewnątrz itemContent — przerywa kompozycję; (4) brak contentType dla mieszanych typów — Compose ponownie używa kompozycji reklamy dla posta tekstowego, powodując artefakty wizualne. W IT Sectr dodajemy contentType do wszystkich list z trzema i więcej typami zawartości.
Często zadawane pytania
Sprawdź trzy parametry: (1) key — bez stabilnego klucza Compose nie może ponownie użyć kompozycji, (2) contentType — dla list z różnymi typami elementów, (3) beyondBoundsPageCount — zwiększ do 2 dla obrazów. Ciężkie obliczenia wewnątrz itemContent przenoś do remember z kluczem według danych. Używaj Modifier.drawWithContent zamiast Image dla prostej grafiki.
Używaj LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) dla odstępów między elementami. Dla wizualnego separatora z linią dodaj item { Divider() } między items. Automatyczny separator: items(items, key = { it.id }) { item -> ... }, a między każdym elementem wstaw item { HorizontalDivider() }. W tym celu użyj LazyListScope.items(list, key) { /* element */ } i osobno LazyListScope.item { Divider() }.
LazyColumn ma domyślnie nieskończoną wysokość (fillMaxSize). Wewnątrz Column bez stałej wysokości LazyColumn nie może określić rozmiaru. Rozwiązanie: (1) ustaw LazyColumn stałą wysokość: Modifier.height(400.dp), (2) użyj Modifier.weight(1f) wewnątrz Column, (3) nie umieszczaj LazyColumn w pionowo przewijanym kontenerze — używaj LazyColumn jako elementu głównego z item {} dla headera i footera.
LazyColumn automatycznie reaguje na zmiany State. Jeśli dane są przechowywane w mutableStateListOf lub mutableStateOf, Compose ponownie komponuje tylko zmienione elementy. Dla list z ViewModel używaj collectAsState() z Flow. Przy aktualizacji listy z key Compose automatycznie animuje zmiany (dodawanie, usuwanie, przenoszenie) przez Modifier.animateItemPlacement().
LazyColumn — dla list z jedną kolumną (kanał, czat, komentarze). LazyVerticalGrid — dla siatek (galeria, katalog, ikony). Jeśli elementy mają być w jednej kolumnie na telefonie i w dwóch na tablecie — używaj LazyVerticalGrid z GridCells.Adaptive. Jeśli potrzebna jest mieszana siatka (różna liczba kolumn w jednej sekcji) — używaj LazyColumn i wewnątrz items wstawiaj poziome LazyRow lub zagnieżdżone LazyVerticalGrid.
Podsumowanie
Opracujemy aplikację mobilną pod klucz
IT Sectr tworzy aplikacje na iOS i Androida dla startupów i firm od 2017 roku. Doradzimy Ci i zaproponujemy najlepsze rozwiązanie.
Przeczytaj również