androidandroid-jetpack-composeandroid-jetpack-compose-lazy-column

LazyRow non-observable firstVisibleItemIndex?


I'm tring to find max() on a subset of initial data before laying out an item of a LazyRow. The subset will consist of only visible items and I need to read firstVisibleItemIndex without triggering recompositions.

Related code:

@Composable
fun TestComposable(modifier: Modifier = Modifier) {
    val scrollState = rememberLazyListState()

    val dataList = mutableListOf<Int>().apply {
        repeat(100) {
            add((0..100).random())
        }
    }
    val windowSize = 4

    LazyRow(
        state = scrollState
    ) {
        itemsIndexed(dataList,
            key = { index, item -> index }) { index, item ->
            val firstIndex = scrollState.firstVisibleItemIndex // <-- Observable and causes recompositions

            val endIndex = firstIndex + windowSize
            val lastIndex = if (endIndex > dataList.size - 1)
                dataList.size - 1
            else
                endIndex
            val max = dataList
                .subList(firstIndex, lastIndex) // For simplicity let it be sublist here
                .max()

            Text(max.toString())
        }
    }
}

How should I access firstVisibleItemIndex value without recompositions?


Solution

  • To access firstVisibleItemIndex without triggering recompositions in a LazyRow, you can use LaunchedEffect with a snapshotFlow. This approach observes changes to scrollState.firstVisibleItemIndex and updates a MutableState or performs computations as needed without directly tying the observable value to composable recompositions.

    Here’s how you can implement it:

    @Composable
    fun TestComposable(modifier: Modifier = Modifier) {
        val scrollState = rememberLazyListState()
    
        val dataList = mutableListOf<Int>().apply {
            repeat(100) {
                add((0..100).random())
            }
        }
        val windowSize = 4
    
        // State to hold the max value
        val maxForVisibleItems = remember { mutableStateOf(0) }
    
        // Launch a side effect to observe firstVisibleItemIndex changes
        LaunchedEffect(scrollState) {
            snapshotFlow { scrollState.firstVisibleItemIndex }
                .collect { firstIndex ->
                    val endIndex = firstIndex + windowSize
                    val lastIndex = if (endIndex > dataList.size - 1) dataList.size - 1 else endIndex
                    maxForVisibleItems.value = dataList.subList(firstIndex, lastIndex).maxOrNull() ?: 0
                }
        }
    
        LazyRow(
            state = scrollState
        ) {
            itemsIndexed(dataList, key = { index, item -> index }) { index, item ->
                Text(maxForVisibleItems.value.toString()) // Use the precomputed max value
            }
        }
    }
    

    Explanation:

    Using snapshotFlow: The snapshotFlow observes changes to the firstVisibleItemIndex without causing recompositions. It captures the value of scrollState.firstVisibleItemIndex in a coroutine.

    Updating State in LaunchedEffect: Inside the collect block, compute the max for the visible items and update maxForVisibleItems. This ensures that the computation runs only when firstVisibleItemIndex changes.

    Displaying the Max Value: The Text composable uses the precomputed value from maxForVisibleItems, avoiding unnecessary recompositions due to direct observation of firstVisibleItemIndex.

    Handling Edge Cases: If the list is empty or the computation yields no results, maxOrNull ensures safe handling with a default fallback.