Skip to Content

LazyColumn và LazyRow

Hướng dẫn hiển thị danh sách hiệu quả với List có trạng thái loading, error, empty.

LazyColumn cơ bản

@Composable fun BasicLazyColumn() { LazyColumn { items(100) { index -> Text( text = "Item $index", modifier = Modifier .fillMaxWidth() .padding(16.dp) ) } } }

LazyColumn với Data Class

@Composable fun UserList(users: List<User>) { LazyColumn( contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { items( items = users, key = { it.id } // Unique key cho performance ) { user -> UserCard(user = user) } } } @Composable fun UserCard(user: User) { Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp) ) { Row( modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically ) { Box( modifier = Modifier .size(48.dp) .background(Color.Gray, CircleShape), contentAlignment = Alignment.Center ) { Text(user.name.first().toString(), color = Color.White) } Spacer(Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { Text(user.name, fontWeight = FontWeight.Bold) Text(user.email, color = Color.Gray) } } } }

@Composable fun ListWithHeaderFooter(items: List<String>) { LazyColumn { // Header item { Text( "Header", modifier = Modifier.padding(16.dp), style = MaterialTheme.typography.headlineSmall ) } // Items items(items) { item -> Text(item, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) } // Footer item { Text( "Footer - ${items.size} items", modifier = Modifier.padding(16.dp), color = Color.Gray ) } } }

Sticky Headers

@OptIn(ExperimentalFoundationApi::class) @Composable fun StickyHeaderList(groupedItems: Map<String, List<Contact>>) { LazyColumn { groupedItems.forEach { (initial, contacts) -> stickyHeader { Text( text = initial, modifier = Modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.primaryContainer) .padding(16.dp), fontWeight = FontWeight.Bold ) } items(contacts) { contact -> Text( contact.name, modifier = Modifier.padding(16.dp) ) } } } }

List States Pattern

UI State sealed class

sealed class ListUiState<out T> { object Loading : ListUiState<Nothing>() data class Success<T>(val data: List<T>) : ListUiState<T>() data class Error(val message: String) : ListUiState<Nothing>() object Empty : ListUiState<Nothing>() }

Stateful List Screen

@Composable fun <T> StatefulList( state: ListUiState<T>, onRetry: () -> Unit, onRefresh: () -> Unit, itemContent: @Composable (T) -> Unit ) { Box(modifier = Modifier.fillMaxSize()) { when (state) { is ListUiState.Loading -> { LoadingState() } is ListUiState.Success -> { if (state.data.isEmpty()) { EmptyState(onAction = onRefresh) } else { SuccessState( items = state.data, onRefresh = onRefresh, itemContent = itemContent ) } } is ListUiState.Error -> { ErrorState( message = state.message, onRetry = onRetry ) } is ListUiState.Empty -> { EmptyState(onAction = onRefresh) } } } } @Composable private fun LoadingState() { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { CircularProgressIndicator() Spacer(Modifier.height(16.dp)) Text("Loading...") } } } @Composable private fun EmptyState(onAction: () -> Unit) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Icon( Icons.Default.Inbox, contentDescription = null, modifier = Modifier.size(64.dp), tint = Color.Gray ) Spacer(Modifier.height(16.dp)) Text("No items found", color = Color.Gray) Spacer(Modifier.height(16.dp)) Button(onClick = onAction) { Text("Refresh") } } } } @Composable private fun ErrorState(message: String, onRetry: () -> Unit) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Icon( Icons.Default.Warning, contentDescription = null, modifier = Modifier.size(64.dp), tint = MaterialTheme.colorScheme.error ) Spacer(Modifier.height(16.dp)) Text(message, color = MaterialTheme.colorScheme.error) Spacer(Modifier.height(16.dp)) Button(onClick = onRetry) { Icon(Icons.Default.Refresh, contentDescription = null) Spacer(Modifier.width(8.dp)) Text("Retry") } } } } @Composable private fun <T> SuccessState( items: List<T>, onRefresh: () -> Unit, itemContent: @Composable (T) -> Unit ) { LazyColumn( contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { items(items) { item -> itemContent(item) } } }

Sử dụng

@Composable fun UserListScreen(viewModel: UserListViewModel) { val state by viewModel.uiState.collectAsState() Scaffold( topBar = { TopAppBar( title = { Text("Users") }, actions = { IconButton(onClick = { viewModel.refresh() }) { Icon(Icons.Default.Refresh, "Refresh") } } ) } ) { padding -> Box(modifier = Modifier.padding(padding)) { StatefulList( state = state, onRetry = { viewModel.loadUsers() }, onRefresh = { viewModel.refresh() } ) { user -> UserCard(user = user) } } } }

Pull to Refresh

@OptIn(ExperimentalMaterial3Api::class) @Composable fun PullToRefreshList( users: List<User>, isRefreshing: Boolean, onRefresh: () -> Unit ) { val pullToRefreshState = rememberPullToRefreshState() Box( modifier = Modifier .fillMaxSize() .nestedScroll(pullToRefreshState.nestedScrollConnection) ) { LazyColumn( contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { items(users, key = { it.id }) { user -> UserCard(user = user) } } if (pullToRefreshState.isRefreshing) { LaunchedEffect(true) { onRefresh() } } LaunchedEffect(isRefreshing) { if (isRefreshing) { pullToRefreshState.startRefresh() } else { pullToRefreshState.endRefresh() } } PullToRefreshContainer( state = pullToRefreshState, modifier = Modifier.align(Alignment.TopCenter) ) } }

LazyRow

@Composable fun HorizontalList(items: List<Category>) { LazyRow( contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { items(items) { category -> CategoryChip(category) } } } @Composable fun CategoryChip(category: Category) { FilterChip( selected = category.isSelected, onClick = { }, label = { Text(category.name) } ) }

Pagination

@Composable fun PaginatedList( items: List<User>, isLoadingMore: Boolean, onLoadMore: () -> Unit ) { val listState = rememberLazyListState() // Detect khi scroll gần cuối LaunchedEffect(listState) { snapshotFlow { val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 val totalItems = listState.layoutInfo.totalItemsCount lastVisibleItem >= totalItems - 3 }.collect { shouldLoadMore -> if (shouldLoadMore && !isLoadingMore) { onLoadMore() } } } LazyColumn( state = listState, contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { items(items, key = { it.id }) { user -> UserCard(user) } // Loading indicator ở cuối if (isLoadingMore) { item { Box( modifier = Modifier .fillMaxWidth() .padding(16.dp), contentAlignment = Alignment.Center ) { CircularProgressIndicator() } } } } }

📝 Tóm tắt

ComponentMục đích
LazyColumnDanh sách dọc hiệu quả
LazyRowDanh sách ngang
items()Render list items
item()Single item (header/footer)
stickyHeaderHeader cố định khi scroll
keyUnique identifier cho performance

Best Practices

  1. Luôn dùng key cho performance
  2. Handle all states: Loading, Error, Empty, Success
  3. Pull to refresh cho UX tốt hơn
  4. Pagination cho danh sách lớn

Tiếp theo

Học về Trang Login với form validation.

Last updated on