Home > Blockchain >  Is rememberDismissState cached for LazyColumn Key?
Is rememberDismissState cached for LazyColumn Key?

Time:12-05

I have composable

fun ShowProduct(name: String, image: String, onDismissed: () -> Unit) {
    var state = rememberDismissState();

    if (state.isDismissed(DismissDirection.EndToStart)) {
        onDismissed()
    }

    SwipeToDismiss(
        state = state,
        background = { ShowSwipableActions(name) },
        modifier = Modifier
            .padding(10.dp, 10.dp)
            .height(75.dp),
        directions = setOf(DismissDirection.EndToStart),
        dismissThresholds = { _ -> FractionalThreshold(0.5f) }
    ) {
        /* Content */
    }
}

Which is rendered like this

@Composable
fun ProductsScreen(vm: ProductsListViewModel = ProductsListViewModel()){
    ShowList(
        vm.products,
        { x -> vm.removeProduct(x) },
        vm.isLoadingProducts.value,
        {
            vm.refresh()
        }
    )
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ShowList(
    products: List<Product>,
    onDismissed: (productId: String) -> Unit,
    refreshing: Boolean,
    onRefreshRequested: () -> Unit
) {
    val haptic = LocalHapticFeedback.current

    PullToRefreshCompose(
        refreshing,
        onRefreshRequested = onRefreshRequested,
        onRefreshDistanceReached = {
            haptic.performHapticFeedback(HapticFeedbackType.LongPress)
        }) {
        LazyColumn(modifier = Modifier.fillMaxSize()) {
            items(items = products, key = { product -> product.id }) { product ->
                ShowProduct(
                    product.name,
                    product.image,
                    onDismissed = { onDismissed.invoke(product.id) });
            }
        }
    }

}

ViewModel:

class ProductsListViewModel() : ViewModel() {
    private var _products = mutableStateListOf<Product>()
    val products: List<Product>
        get() = _products

    var isLoadingProducts = mutableStateOf(false);

    fun removeProduct(id: String) {
        _products.removeIf { x -> x.id == id }
    }

    fun refresh() {
            isLoadingProducts.value = true

            if(_products.any()){
                _products.clear()
            }

            for (i in 0..50) {
                _products.add(
                    Product(
                        i.toString(),
                        "Product $i",
                        "*image url*"
                    )
                );
            }

            isLoadingProducts.value = false
    }
}

If I dismiss an item and then call the refresh() function in my ViewModel, the dismissed keys will be displayed already in the dismissed state. Should I use completely unique keys for the entire lifetime of LazyColumn if I delete and add the same item?

For example


i.toString()   System.currentTimeMillis()

CodePudding user response:

Since your posted code is incomplete, I just assumed some parts of it, and when I filled the missing parts, I only created 2 items instead of 50 as I can't differentiate every red Box that I created with that many items. What I encountered was a crash, based on the GIF showing the actions being done. After swipe-deleting the 2 items and clicking the "Refresh" button, it crashes with the stacktrace below.

E/AndroidRuntime: FATAL EXCEPTION: main
    Process: com.example.stackoverflowcomposeproject, PID: 24991
    java.lang.IndexOutOfBoundsException: Index 1, size 1
        at androidx.compose.foundation.lazy.layout.MutableIntervalList.checkIndexBounds(IntervalList.kt:177)
        at androidx.compose.foundation.lazy.layout.MutableIntervalList.get(IntervalList.kt:160)
        at androidx.compose.foundation.lazy.layout.DefaultLazyLayoutItemsProvider.getKey(LazyLayoutItemProvider.kt:236)

The issue was coming from here

if (state.isDismissed(DismissDirection.EndToStart)) {
    onDismissed()
}

It seems like when your SnapshotList gets cleared and you immediately filled it with items, the entire composable where the LazyColumn is contained is re-composed, and because you already put items right after clearing, your composable with the Swipe components also is re-created/re-composed, which evaluates the condition above that calls your callback onDismiss, also calls your removeProduct function in your viewModel, where your'e trying to delete something that seems like from an invalid index.

It may seem weird at first as everyone might expect that rememberDismissState will create a new state when your ShowList re-composes, but if you dig it a little bit, you'll see its API as a rememberSaveable{…} and AFAIK, it survives re-composition.

@Composable
@ExperimentalMaterialApi
fun rememberDismissState(
    initialValue: DismissValue = Default,
    confirmStateChange: (DismissValue) -> Boolean = { true }
): DismissState {
    return rememberSaveable(saver = DismissState.Saver(confirmStateChange)) {
        DismissState(initialValue, confirmStateChange)
    }
}

The fix on my encountered issue, is just to create a remembered{..} DismissState. (Note I'm not sure if there would be any other repercussions doing this aside from not surviving config changes such as screen rotation, but it solves the crash I encountered, might also solve yours)

val state = remember {
      DismissState(
         initialValue = DismissValue.Default
      )
}

Another thing I did is (not a fix but maybe an optimization) is I wrapped your dismissState function calls inside a derivedStateOf, because not doing so(like yours) will execute multiple re-compositions on its enclosing composable

val isDismissed by remember {
     derivedStateOf {
          state.isDismissed(DismissDirection.EndToStart)
     }
}

// used like this
if (isDismissed) {
    onDismissed()
}

And these are your modified components (all codes you posted).

// your Data class that I assumed
data class Product(
    val id : String,
    val name: String
)

// your Screen where I removed unnecessary codes to reproduce the issue
@Composable
fun ProductsScreen(vm: ProductsListViewModel = ProductsListViewModel()){
    ShowList(
        vm,
        { x -> vm.removeProduct(x) },
        { vm.refresh() }
    )
}

// your ViewModel where I removed unnecessary codes to reproduce the issue
class ProductsListViewModel : ViewModel() {

     var products = mutableStateListOf<Product>()

    fun removeProduct(id: String) {
        products.removeIf { x -> x.id == id }
    }

    fun refresh() {

        if(products.any()) {
            products.clear()
        }

        for (i in 0..1) {
            products.add(
                Product(
                    i.toString(),
                    "Product $i"
                )
            )
        }
    }
}

// your ShowProduct where I removed unnecessary codes to reproduce the issue
// added swipe back background and a red rectangular box to see an item
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun ShowProduct(
    onDismissed: () -> Unit
) {

    val state = remember {
        DismissState(
            initialValue = DismissValue.Default
        )
    }

    val isDismissed by remember {
        derivedStateOf {
            state.isDismissed(DismissDirection.EndToStart)
        }
    }

    if (isDismissed) {
        onDismissed()
    }

    SwipeToDismiss(
        state = state,
        background = {
            Text("SomeSwipeContent")
        },
        modifier = Modifier
            .padding(10.dp, 10.dp)
            .height(75.dp),
        directions = setOf(DismissDirection.EndToStart),
        dismissThresholds = { _ -> FractionalThreshold(0.5f) }
    ) {
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .height(50.dp)
                .background(Color.Red)
        )
    }
}


// your ShowList where I removed unnecessary codes to reproduce the issue
// and added a button to make it work
@Composable
fun ShowList(
    viewModel: ProductsListViewModel,
    onDismissed: (String) -> Unit,
    onRefreshRequested: () -> Unit
) {

    Column {

        Button(onClick = { onRefreshRequested() }) {
            Text("Refresh")
        }

        LazyColumn(
            modifier = Modifier.fillMaxSize()) {
            items(items = viewModel.products, key = { product -> product.id } ) { product ->

                ShowProduct(
                    onDismissed = {
                        onDismissed(product.id)
                    }
                )
            }
        }
    }
}

All of them were used like this

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
        setContent {
            ProductsScreen()
        }
}

Output: (The gif doesn't show the crashing, but if you use rememberDismissState instead, it will crash after clicking the button)

enter image description here

Note: Everything I did is to fix the crash issue that I encountered, but since I'm using your code and I just filled the missing parts, maybe it will solve yours.

  • Related