Infinite Scroll in Compose: Auto-Loading LazyColumn Implementation
Implementing infinite scroll in Jetpack Compose requires coordinating LazyColumn state detection with ViewModel-level pagination logic. This guide covers production-ready patterns for seamless auto-loading experiences.
Architecture Overview
ViewModel with Pagination State
class PaginatedViewModel : ViewModel() {
private val _items = MutableStateFlow<List<Item>>(emptyList())
val items = _items.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading = _isLoading.asStateFlow()
private val _hasMore = MutableStateFlow(true)
val hasMore = _hasMore.asStateFlow()
private var currentPage = 1
private val pageSize = 20
fun loadMoreItems() {
if (_isLoading.value || !_hasMore.value) return
viewModelScope.launch {
_isLoading.value = true
try {
val newItems = fetchItemsFromApi(page = currentPage, limit = pageSize)
_items.value += newItems
_hasMore.value = newItems.size == pageSize
currentPage++
} catch (e: Exception) {
// Handle error
} finally {
_isLoading.value = false
}
}
}
private suspend fun fetchItemsFromApi(page: Int, limit: Int): List<Item> {
// API call implementation
return emptyList()
}
}
End-of-List Detection
Using derivedStateOf + snapshotFlow
Detect when user scrolls within 5 items of the list end:
@Composable
fun PaginatedList(viewModel: PaginatedViewModel) {
val items by viewModel.items.collectAsState(initial = emptyList())
val isLoading by viewModel.isLoading.collectAsState(initial = false)
val hasMore by viewModel.hasMore.collectAsState(initial = true)
val listState = rememberLazyListState()
// Detect when scrolled near end
val shouldLoadMore = remember {
derivedStateOf {
val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo
.lastOrNull()?.index ?: 0
val totalItems = listState.layoutInfo.totalItemsCount
lastVisibleIndex >= totalItems - 5 // Pre-fetch at 5 items from end
}
}
// Trigger load when threshold reached
LaunchedEffect(shouldLoadMore.value) {
if (shouldLoadMore.value && hasMore && !isLoading) {
viewModel.loadMoreItems()
}
}
LazyColumn(state = listState) {
items(items.size) { index ->
ItemRow(items[index])
}
// Loading indicator at bottom
if (isLoading) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
}
}
Pull-to-Refresh Integration
Combine infinite scroll with refresh functionality:
@Composable
fun RefreshablePaginatedList(viewModel: PaginatedViewModel) {
val pullRefreshState = rememberPullRefreshState(
refreshing = false,
onRefresh = { /* Reset pagination and reload */ }
)
Box(modifier = Modifier.pullRefresh(pullRefreshState)) {
PaginatedList(viewModel)
PullRefreshIndicator(
refreshing = false,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter)
)
}
}
Error Handling
@Composable
fun RobustPaginatedList(viewModel: PaginatedViewModel) {
val items by viewModel.items.collectAsState(initial = emptyList())
val isLoading by viewModel.isLoading.collectAsState(initial = false)
val error by viewModel.error.collectAsState(initial = null)
Column {
if (error != null) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFFFFEBEE))
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(error ?: "Unknown error", modifier = Modifier.weight(1f))
Button(onClick = { viewModel.loadMoreItems() }) {
Text("Retry")
}
}
}
}
PaginatedList(viewModel)
}
}
Performance Optimization
SnapshotFlow for Efficient Updates
LaunchedEffect(Unit) {
snapshotFlow { shouldLoadMore.value }
.distinctUntilChanged()
.collect { shouldLoad ->
if (shouldLoad && hasMore && !isLoading) {
viewModel.loadMoreItems()
}
}
}
Best Practices
- Pre-fetch threshold - Load 5 items before end to avoid blank space
-
Loading state management - Prevent duplicate requests with
isLoadingguard - Error recovery - Implement retry button with exponential backoff
-
Memory efficiency - Consider
maxSizeparameter in Pager for large lists -
State preservation - Use
LazyListStatewithrememberLazyListState()for configuration changes
Data Models
data class Item(
val id: String,
val title: String,
val description: String
)
Complete Composable
@Composable
fun InfiniteScrollScreen(viewModel: PaginatedViewModel = hiltViewModel()) {
val items by viewModel.items.collectAsState(initial = emptyList())
val isLoading by viewModel.isLoading.collectAsState(initial = false)
Column(modifier = Modifier.fillMaxSize()) {
Text(
text = "Items (${items.size})",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(16.dp)
)
RobustPaginatedList(viewModel)
}
}
Troubleshooting
-
Duplicate loads: Add
isLoadingcheck before triggeringloadMoreItems() - Blank space at bottom: Reduce pre-fetch threshold from 5 to 2-3 items
-
Memory issues: Implement paging library with
RemoteMediatorfor database caching
8 Android App Templates → https://myougatheax.gumroad.com
Top comments (0)