androidkotlinuser-interfaceandroid-jetpack-composelazycolumn

How to avoid lazy column index duplication after item swap


I want to make some item swap mechanism in LazyColumn items. For this I have used itemIndexed but after a swipe, index numbers becomes the same. For example if I swap first item with second the item index for both become 0. I suspect this behavior because of reuse of component in LazyColumn.

@Composable
fun Screen(modifier:Modifier = Modifier) {
    val itemList = remember { mutableStateListOf("Item 1", "Item 2", "Item 3", "Item 4") }
    val context = LocalContext.current

    LazyColumn(modifier = modifier) {
        itemsIndexed(itemList, key = { _, text -> text }) { index, text ->
            Card(
                modifier = Modifier
                    .pointerInput(Unit) {
                        detectTapGestures(
                            onTap = {
                                Toast.makeText(context, "" + index, Toast.LENGTH_SHORT).show()
                            },
                            onDoubleTap = {
                                Collections.swap(itemList, index, index + 1)
                            }
                        )
                    }
                    .padding(8.dp)
                    .fillMaxWidth()
                    .animateItem()
            ) {
                Row(
                    modifier =  Modifier.padding(16.dp).fillMaxSize()
                ) {
                    Text("$index. $text")
                }
            }
        }
    }
}

In this example I can tap to look index number of the item and double tap on a item to swap with bottom item.

Test : If I click on first item it toast 0 and second item toast 1. Now when I double tap on the first item or second item and then do the same both toast 0. But the Text displaying correct indexes in both cases.

I came up with solution but it is very inefficient nor feels the right way. Which is declaring variable index inside items and reassign it before the Text with the index for itemIndexed (notice the lambda parameter index change to i). But multiple recompsitions happens this.

@Composable
fun Screen(modifier:Modifier = Modifier) {
    ...
    LazyColumn(modifier = modifier) {
        itemsIndexed(...) { i/*index change to i*/, text ->
            // New Index declaration
            var index by remember { mutableIntStateOf(i) }

            Card(...) {
                Row(
                    modifier =  Modifier.padding(16.dp).fillMaxSize()
                ) {
                    index = i // Reassigns here
                    Text("$index. $text")
                }
            }
        }
    }
}

Any help is appreciated.


Solution

  • The pointerInput's lambda is the problem. It captures the itemList object and the index object. When you reorder the list, the lambda stays the same because the key you provide for pointerInput didn't change. You provide Unit, and that is always the same. From the documentation:

    The pointer input handling block will be cancelled and re-started when pointerInput is recomposed with a different key1 [...].

    That means the captured objects of the lambda won't update. For itemList that isn't an issue because only the content of the list is modified, but it is still the same list object. That's different for the captured index objects, they don't match the current index anymore.

    For composables that provide key parameters so the lambda provided during the previous composition isn't touched (like remember, LaunchedEffect and also pointerInput), it is important to provide all objects as keys that should still lead to an updated lambda. You should check each outside variable you access inside your lambda if it is important that it is updated. In your case you access these variables:

    You always want the lambda to access the same index that itemsIndexed provides, so you must pass this as a key to pointerInput.
    You also need to access the current itemList, but that actually never changes (it is declared as val, not var, and the list is remembered, so it stays the same object on each recomposition; only its content is modified). It wouldn't be wrong to also pass it as a key, but you don't need to.
    Toast.LENGTH_SHORT is a constant (more precise, it's the Java equivalent, a static final variable), so this can never change and shouldn't be passed as a key.
    The context actually can change in some situations. Although not relevant to your current problem, you should actually pass that as a key too.

    It should look like this:

    .pointerInput(context, index) {
        detectTapGestures(
            onTap = {
                Toast.makeText(context, "" + index, Toast.LENGTH_SHORT).show()
            },
            onDoubleTap = {
                Collections.swap(itemList, index, index + 1)
            }
        )
    }
    

    And that's it, the pointerInput lambda is now updated when needed so you can always access the correct index.