androidkotlinandroid-jetpack-compose

The exit and enter animate of AnimatedVisibility are not displayed correctly


I try to use AnimatedVisibility and Modifier.pointerInput function to achieve such an effect that user can swipe it out to delete item in list. I found that exit and enter animate are not displayed correctly, which is confused me. Item is appeared and disappeared without any animate. I don't know what went wrong. I'm wondering why this is, or if there's any way to help me determine why the expected animation isn't working. Thanks in advance.

@Composable
fun SwipedContent (
    swipeThreshold: Float = 500f,
    isVisibility: () -> Boolean,
    modifier: Modifier = Modifier,
    onSwipe: () -> Unit,
    content: @Composable () -> Unit
) {
    val offsetX = remember { Animatable(0f) }

    val scope = rememberCoroutineScope()

    AnimatedVisibility (
        visible = isVisibility.invoke(),
        enter = fadeIn(tween(300)) + slideInHorizontally(
            initialOffsetX = { it }
        ),
        exit = fadeOut(tween(300)) + slideOutHorizontally(
            targetOffsetX = { fullWidth ->
                if (fullWidth > 0) {
                    fullWidth
                } else {
                    -fullWidth
                }
            }
        )
    ) {
        Box (
            modifier = modifier
                .pointerInput(Unit) {
                    detectHorizontalDragGestures(
                        onDragEnd = {
                            if (offsetX.value > swipeThreshold || offsetX.value < -swipeThreshold) {
                                onSwipe.invoke()
                            } else {
                                scope.launch { offsetX.animateTo(0f) }
                            }
                        }
                    ) { change, dragAmount ->
                        change.consume()
                        scope.launch { offsetX.snapTo(offsetX.value + dragAmount) }
                    }
                }
                .offset { IntOffset(offsetX.value.roundToInt(), 0) },
        ) {
            content.invoke()
        }
    }
}
initialAlertList.forEachIndexed { index, alert ->
            key (alert.id) {
                SwipedContent (
                    isVisibility = { initialAlertList.contains(alert) },
                    onSwipe = { initialAlertList.remove(alert) }
                ) {
                    Card (
                        modifier = Modifier
                            .fillMaxWidth()
                            .padding(vertical = 8.dp, horizontal = 5.dp)
                            .clickable(
                                onClick = {
                                    editAlertIndexState.intValue = index
                                    isShowPopUp.value = !isShowPopUp.value
                                },
                                indication = ripple(),
                                interactionSource = remember { MutableInteractionSource() }
                            ),
                        shape = RoundedCornerShape(8.dp),
                        colors = CardDefaults.cardColors().copy(containerColor = Color.Transparent),
                        border = BorderStroke(width = 1.dp, color = Color.Gray),
                    ) {
                        // someComponents here
                    }
                }
            }
        }

Solution

  • In the onSwipe callback you remove the item from the list. This triggers a recomposition and the initialAlertList.forEachIndexed loop is executed again, but the item in question isn't there anymore so the SwipedContent for that item is skipped, as well as the AnimatedVisibility inside which therefore isn't able to display the exit animation anymore.

    You would need to keep the item in the list when swiped and instead mark it as removed. One way would be to add a new property to the Alert data class:

    val visible: Boolean = true,
    

    Then you can call SwipedContent like this instead:

    SwipedContent(
        isVisibility = { alert.visible },
        onSwipe = { initialAlertList[index] = alert.copy(visible = false) },
    )
    

    When the user swipes, the item is marked as visible = false but still remains in the list. On recomposition the SwipedContent will still be called for this "removed" item (it's not really removed, just marked as invisible) and its AnimatedVisibility will recognize the change in visibility and trigger the exit animation.

    If you want to wait for the animation to end and then really remove the item from the list, see here: https://stackoverflow.com/a/68640459


    I guess you have placed the initialAlertList.forEachIndexed loop with key in a Column. That seems a bit awkward; why don't you just use a simple LazyColumn that has this built-in instead?

    LazyColumn {
        itemsIndexed(
            items = initialAlertList,
            key = { _, item -> item.id },
        ) { index, alert ->
            SwipedContent(/*...*/)
        }
    }
    

    This would also enable scrolling for the column when the items won't fit and you could even pass Modifier.animateItem() to SwipedContent. This would allow the LazyColumn to animate the items following the removed item to gently slide up to fill its space instead of instantly jumping up. This only works when the item is actually removed from the list, not just set to invisible. See the last paragraph of the previous section.


    And finally, with your SwipedContent you seem to have reinvented the built-in SwipeToDismissBox. For that you would need a rememberSwipeToDismissBoxState() where you can set the 500f threshold, but it also doesn't provide an exit animation that triggers automatically, so you would have to create your own animation when the state's confirmValueChange is called with the desired value.

    Just fyi, maybe that is still a viable alternative for you,