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))
}
}
)
}
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)
)
}
}
}
}