kotlin-multiplatformcompose-multiplatform

Reset of SwipeToDismissBoxState not working


I build a swipetodismiss box for a lazy column, containing a confirmation dialog. This is to make sure, we're not accidentially deleting an item from a list, so the user needs to confirm.

The happy case is working (user swipes/user confirms), but when I press cancel I am resetting the state (using SwipeToDismissBoxState.reset()).

Everything seems reset (even the item is animating back) except for the fact that I cannot swipe the item again. When the item is moved out of sight (due to scrolling e.g.) and reenters the screen it doesn't seem reset at all.

I am resetting the SwipeToDismissBoxState.reset(), I don't know what I am doing wrong...

Compose-Multiplatform-Version: 1.6.11

Here the snippet, feel free to re-use it, once it's working!


@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun <T> SwipeToDismissContainer(
    item: T,
    itemName: String,
    animationTime: Int = 300,
    confirmationDialog: Boolean = true,
    onDismiss: (T) -> Unit,
    content: @Composable (T) -> Unit
) {
    var potentialDelete by remember { mutableStateOf(false) }
    var deleteItem by remember { mutableStateOf(false) }
    var resetItem by remember { mutableStateOf(false) }

    val state = rememberSwipeToDismissBoxState(
        confirmValueChange = { dismissValue ->
            when (dismissValue) {
                SwipeToDismissBoxValue.EndToStart,
                SwipeToDismissBoxValue.StartToEnd -> {
                    if (confirmationDialog) {
                        potentialDelete = true
                        true
                    } else {
                        potentialDelete = false
                        deleteItem = true
                        true
                    }
                }

                else -> false
            }
        }
    )

    LaunchedEffect(resetItem) {
        if (resetItem) {
            state.reset()
            resetItem = false
        }
    }

    LaunchedEffect(deleteItem) {
        if (deleteItem) {
            delay(animationTime.toLong())
            onDismiss(item)
        }
    }

    AnimatedVisibility(
        visible = !deleteItem,
        exit = shrinkVertically(
            animationSpec = tween(durationMillis = animationTime),
            shrinkTowards = Alignment.Top
        ) + fadeOut()
    ) {
        SwipeToDismissBox(
            state = state,
            backgroundContent = {
                Box(
                    contentAlignment = Alignment.CenterEnd,
                    modifier = Modifier
                        .fillMaxSize()
                        .background(MaterialTheme.colorScheme.errorContainer)
                ) {
                    Icon(
                        modifier = Modifier.minimumInteractiveComponentSize(),
                        imageVector = Icons.Outlined.Delete, contentDescription = null
                    )
                }
            },
            content = {
                content(item)
            }
        )
    }

    if (confirmationDialog && potentialDelete) {
        SwipeDismissConfirmationDialog(
            itemName = itemName,
            onCancel = {
                potentialDelete = false
                resetItem = true
            }
        ) {
            potentialDelete = false
            deleteItem = true
        }
    }
}

@Composable
private fun SwipeDismissConfirmationDialog(
    itemName: String,
    onCancel: () -> Unit,
    onConfirm: () -> Unit,
) {
    AlertDialog(
        icon = { Icon(Icons.Default.Delete, contentDescription = null) },
        title = { Text(text = stringResource(Res.string.item_deletion_dialog_title)) },
        text = {
            Text(
                text = stringResource(
                    Res.string.item_deletion_dialog_dialog_description,
                    itemName
                )
            )
        },
        onDismissRequest = {
            onCancel()
        },
        confirmButton = {
            TextButton(
                onClick = {
                    onConfirm()
                }
            ) {
                Text(text = stringResource(Res.string.item_deletion_dialog_dialog_confirm))
            }
        },
        dismissButton = {
            TextButton(
                onClick = {
                    onCancel()
                }
            ) {
                Text(text = stringResource(Res.string.item_deletion_dialog_dialog_cancel))
            }
        }
    )
}


Solution

  • From what I can tell, this is either intended behavior or a bug in the M3 library. Not ideal, but here's a decent work around that I'm using. I haven't tested it on Multiplat, but it works for Android native.

    The basic idea is to never confirm the value change, then maintain swiped states as if you had confirmed the change. I added a preview for those who may want to play around with the code for their own needs.

    @Composable
    fun <T> SwipeToDismissContainer(
        item: T,
        itemName: String,
        animationTime: Int = 300,
        confirmationDialog: Boolean = true,
        onDismiss: (T, onError: () -> Unit) -> Unit,
        content: @Composable (T) -> Unit
    ) {
        var potentialDelete by remember { mutableStateOf(false) }
        var deleteItem by remember { mutableStateOf(false) }
        var stateToMaintain by remember { mutableStateOf<SwipeToDismissBoxValue?>(null) }
    
        val state = rememberSwipeToDismissBoxState(
            confirmValueChange = { dismissValue ->
                when (dismissValue) {
                    SwipeToDismissBoxValue.EndToStart,
                    SwipeToDismissBoxValue.StartToEnd -> {
                        if (confirmationDialog) {
                            potentialDelete = true
                        } else {
                            potentialDelete = false
                            deleteItem = true
                        }
                        stateToMaintain = dismissValue
                    }
                    else -> {}
                }
                false //Immediately resets the state so we can swipe it again if confirmation is canceled or if deletion fails
            }
        )
    
        //Maintains the row's swiped state while it waits for confirmation and for AnimatedVisibility to hide the item
        LaunchedEffect(stateToMaintain) {
            stateToMaintain?.let {
                state.snapTo(it)
                stateToMaintain = null
            }
        }
    
        LaunchedEffect(deleteItem) {
            if (deleteItem) {
                delay(animationTime.toLong())
                onDismiss(item) {
                    deleteItem = false
                }
            } else {
                //In our app, the onDismiss function also takes in an onError: () -> Unit,
                //which allows us to bring the item back if deletion fails
                state.reset()
            }
        }
    
        AnimatedVisibility(
            visible = !deleteItem,
            exit = shrinkVertically(
                animationSpec = tween(durationMillis = animationTime),
                shrinkTowards = Alignment.Top
            ) + fadeOut()
        ) {
            SwipeToDismissBox(
                state = state,
                backgroundContent = {
                    Box(
                        modifier = Modifier
                            .fillMaxSize()
                            .background(MaterialTheme.colorScheme.errorContainer)
                    )
                },
                content = {
                    content(item)
                }
            )
        }
    
        val scope = rememberCoroutineScope()
        if (confirmationDialog && potentialDelete) {
            SwipeDismissConfirmationDialog(
                itemName = itemName,
                onCancel = {
                    potentialDelete = false
                    scope.launch { state.reset() } //reset() seems to only reset the visual state, not the full state object
                }
            ) {
                potentialDelete = false
                deleteItem = true
            }
        }
    }
    
    @Composable
    private fun SwipeDismissConfirmationDialog(
        itemName: String,
        onCancel: () -> Unit,
        onConfirm: () -> Unit,
    ) {
        AlertDialog(
            title = { Text(text = "Delete Confirmation") },
            text = { Text("Delete $itemName?") },
            onDismissRequest = onCancel,
            confirmButton = {
                TextButton(onClick = onConfirm) {
                    Text(text = "Delete")
                }
            },
            dismissButton = {
                TextButton(onClick = onCancel) {
                    Text(text = "Cancel")
                }
            }
        )
    }
    
    @Preview(showBackground = true)
    @Composable
    private fun Test() {
        val list = (1..100).toList()
        val scope = rememberCoroutineScope()
        Column {
            list.forEach {
                SwipeToDismissContainer(
                    it,
                    it.toString(),
                    onDismiss = { _, onError ->
                        scope.launch {
                            delay(1000)
                            onError()
                        }
                    }
                ) { item ->
                    Text(
                        text = item.toString(),
                        modifier = Modifier
                            .fillMaxWidth()
                            .background(Color.White)
                            .padding(16.dp)
                    )
                }
            }
        }
    }