I have an issue with `SwipeToDismiss` state when *...
# compose
a
I have an issue with
SwipeToDismiss
state when navigating back and forth between screens. I would like to
reset
(suspend function) the
dismissState
as part of a cleanup. (Or “forget” that state when navigating back to this screen) Idea: When the user swipes out a Card in a LazyList the card stays in the swiped out state while a SnackBar appears. If the snackbar gets dismissed, the element is deleted via the viewModel. If the snackbar action (Undo) gets pressed, the dismissState gets reset. Problem: If I navigate to another screen, the coroutineScope the snackbar is opened gets cancelled. The dismissState will never be reset. When I navigate back to this screen, the swiped card will stay swiped. I tried but failed: A) Have a
finally
block in the coroutine to do the cleanup. B) Having a
DisposableEffect
seems to not work as I cannot access the compose state and have no coroutine scope. Sourcecode:
Copy code
edit: in thread
t
Code in thread, please.
I think
SaveableStateHolder
is one solution to this, as you can remove the remembered dismissed state in a
DisposableEffect
. See https://developer.android.com/reference/kotlin/androidx/compose/runtime/saveable/SaveableStateHolder
a
Copy code
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun ProductListScreen(snackbarHostState: SnackbarHostState) {
    val viewModel: ProductListViewModel = hiltViewModel()
    val products by viewModel.productsLiveData.observeAsState(listOf())
    val scope = rememberCoroutineScope()
    LazyColumn(
        contentPadding = PaddingValues(8.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        itemsIndexed(products, key = { _, item -> item.id }) { index, item ->
            var dismissState: DismissState? = null

            fun dismissed() {
                scope.launch {
                    val snackBarResult = snackbarHostState.showSnackbar(
                        "About to be deleted: ${item.name}",
                        actionLabel = "UNDO",
                        duration = SnackbarDuration.Short
                    )
                    when (snackBarResult) {
                        SnackbarResult.Dismissed -> viewModel.delete(item)
                        SnackbarResult.ActionPerformed -> dismissState?.reset()
                    }
                }
            }

            dismissState = rememberDismissState(confirmStateChange = {
                when (it) {
                    DismissValue.DismissedToEnd -> false
                    DismissValue.DismissedToStart -> {
                        dismissed()
                        true
                    }
                    else -> true
                }
            })
            SwipeToDismiss(
                state = dismissState,
                background = SwipeDismissBackground(dismissState, item),
                directions = setOf(DismissDirection.EndToStart),
                dismissThresholds = { FractionalThreshold(0.75f) },
            ) {
                ProductListCard(index, item, today())
            }
        }
    }
}
@tad Thanks. I seem to still miss something. If I try the following, the onDisposed{} gets called but the state still persists. I’d appreciate if you could give me a hint how to use it.
Copy code
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun ProductListScreen(snackbarHostState: SnackbarHostState) {
    val viewModel: ProductListViewModel = hiltViewModel()
    val products by viewModel.productsLiveData.observeAsState(listOf())
    val scope = rememberCoroutineScope()
    LazyColumn(
        contentPadding = PaddingValues(8.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        itemsIndexed(products, key = { _, item -> item.id }) { index, item ->
            
            val saveableStateHolder = rememberSaveableStateHolder()
            saveableStateHolder.SaveableStateProvider(key = item.id) {

                var dismissState: DismissState? = null

                fun dismissed() {
                    scope.launch {
                        val snackBarResult = snackbarHostState.showSnackbar(
                            "About to be deleted: ${item.name}",
                            actionLabel = "UNDO",
                            duration = SnackbarDuration.Short
                        )
                        when (snackBarResult) {
                            SnackbarResult.Dismissed -> viewModel.delete(item)
                            SnackbarResult.ActionPerformed -> dismissState?.reset()
                        }
                    }
                }

                dismissState = rememberDismissState(confirmStateChange = {
                    when (it) {
                        DismissValue.DismissedToEnd -> false
                        DismissValue.DismissedToStart -> {
                            dismissed()
                            true
                        }
                        else -> true
                    }
                })
                SwipeToDismiss(
                    state = dismissState,
                    background = SwipeDismissBackground(dismissState, item),
                    directions = setOf(DismissDirection.EndToStart),
                    dismissThresholds = { FractionalThreshold(0.75f) },
                ) {
                    ProductListCard(index, item, today())
                }
            }
            DisposableEffect(key1 = item.id){
                onDispose {
                    saveableStateHolder.removeState(item.id)
                }
            }
        }
    }
}
t
try replacing
key1 = item.id
in the DisposableEffect with
Unit
not sure if that will make a difference
and I would move the saveableStateHolder outside of LazyColumn
a
@tad both had no effect 😞
t
hmm. I wonder if you need to hoist a separate dismissed state, so you're not fighting the system here. Maybe try pulling the item composable into a function with
dismissed: Boolean, onDismiss: () -> Unit
parameters, and remember your own boolean value.
a
@tad Could you please write this a bit more verbose?
How would the
dimissed: Boolean
affect the
dismissState
within the new Item-Composable?
t
You'd use it for the
initialValue
of
rememberDismissState
a
Currently the
initialValue
is always
DEFAULT
(basically what I would like after a reset), no matter any parameter. So what would the
initialValue
be changed to?
t
if (dismissed) DismissValue.DismissedToStart else DissmissValue.Default
a
Currently
initialValue
is hard-coded to
DissmissValue.Default
- I therefore still miss how that would help resetting it to
DissmissValue.Default
?
t
the source of truth would now be the
dismissed
boolean that's passed in
if you don't save that value with
rememberSaveable
, it will be reset when restoring the composition
Alternatively, you can forget everything I suggested and use
remember { DismissState(...) }
instead of
rememberDismissState
, if you don't mind the dismiss state resetting if the process dies.
the source of your problem is that
rememberDismissState
uses
rememberSaveable
under the hood, which causes it to save and restore itself when navigating away and back.
a
I’d still like to know why it then didn’t work to remove the state with
Copy code
DisposableEffect(key1 = Unit){
    onDispose {
        saveableStateHolder.removeState(item.id)
    }
}
as all was wrapped with
saveableStateHolder.SaveableStateProvider(key = item.id) {
according to your suggestion.
t
I'm not sure, there's a complication with
LazyColumn
there
And then there's
NavHost
behavior on top of that, which implements its own saveable state registry to maintain state for nav composables
a
Thanks again @tad. The
remember{ DismissState(...) }
worked. That’s a small win. The big win would have been to fully understand state management of Compose in on one long weekend 😉 But well you can’t have everything. Have a nice rest of the holiday or Monday depending on where you are in the world!
t
No problem, glad I could help.
238 Views