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)
}
}
}
}List với Header và Footer
@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
| Component | Mục đích |
|---|---|
LazyColumn | Danh sách dọc hiệu quả |
LazyRow | Danh sách ngang |
items() | Render list items |
item() | Single item (header/footer) |
stickyHeader | Header cố định khi scroll |
key | Unique identifier cho performance |
Best Practices
- Luôn dùng
keycho performance - Handle all states: Loading, Error, Empty, Success
- Pull to refresh cho UX tốt hơn
- Pagination cho danh sách lớn
Tiếp theo
Học về Trang Login với form validation.
Last updated on