DEV Community

myougaTheAxo
myougaTheAxo

Posted on

Infinite Scroll in Compose: Auto-Loading LazyColumn Implementation

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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

Performance Optimization

SnapshotFlow for Efficient Updates

LaunchedEffect(Unit) {
    snapshotFlow { shouldLoadMore.value }
        .distinctUntilChanged()
        .collect { shouldLoad ->
            if (shouldLoad && hasMore && !isLoading) {
                viewModel.loadMoreItems()
            }
        }
}
Enter fullscreen mode Exit fullscreen mode

Best Practices

  1. Pre-fetch threshold - Load 5 items before end to avoid blank space
  2. Loading state management - Prevent duplicate requests with isLoading guard
  3. Error recovery - Implement retry button with exponential backoff
  4. Memory efficiency - Consider maxSize parameter in Pager for large lists
  5. State preservation - Use LazyListState with rememberLazyListState() for configuration changes

Data Models

data class Item(
    val id: String,
    val title: String,
    val description: String
)
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

Troubleshooting

  • Duplicate loads: Add isLoading check before triggering loadMoreItems()
  • Blank space at bottom: Reduce pre-fetch threshold from 5 to 2-3 items
  • Memory issues: Implement paging library with RemoteMediator for database caching

8 Android App Templates → https://myougatheax.gumroad.com

Top comments (0)