androidandroid-jetpack-composeandroid-animationswipe

Jetpack Compose Lazy Column swipe to delete animation does not end smoothly


I am implementing a simple TODO app using Jetpack Compose. I have the following problem: when I try to delete an element (card) from my LazyColumn by swiping from right to left the animation doesn't look good. When it comes to its end, it almost looks like the card beneath the deleted one jumps on the empty space left by the deleted one, see the GIF to understand better:

swipe to delete animation

I would like the animation to end "smoothly" without the card below the deleted one jumping up. I suspect the problem may be related to the verticalArrangement = Arrangement.spacedBy(32.dp) I set up on the LazyColumn (maybe because the animation doesn't consider this extra space), but I need it to separate every card from each other.

Here's my code:

MainScren.kt

package com.pochopsp.dailytasks.presentation.screen

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Sort
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.FilterAlt
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.pochopsp.dailytasks.R
import com.pochopsp.dailytasks.data.database.TaskEvent
import com.pochopsp.dailytasks.data.database.TaskState
import com.pochopsp.dailytasks.presentation.AddTaskDialog
import com.pochopsp.dailytasks.presentation.common.SwipeToDeleteContainer
import com.pochopsp.dailytasks.presentation.tasks.TaskCard

@Composable
fun MainScreen(
    state: TaskState,
    onEvent: (TaskEvent) -> Unit
) {

    Scaffold(
        modifier = Modifier,
        content = { paddingValues ->

            if(state.isAddingTask){
                AddTaskDialog(state = state, onEvent = onEvent)
            }

            Column(
                modifier = Modifier.padding(start = 30.dp, end = 30.dp,
                    bottom = paddingValues.calculateBottomPadding(),
                    top = paddingValues.calculateTopPadding())
            ) {
                Row (
                    verticalAlignment = Alignment.CenterVertically,
                    modifier = Modifier.weight(1f)
                ){
                    Text(
                        text = stringResource(R.string.done_tasks_count,
                            state.tasks.filter { t -> t.done }.size, state.tasks.size),
                        fontWeight = FontWeight.SemiBold,
                        modifier = Modifier.weight(2f)
                    )
                    Spacer(modifier = Modifier.weight(1.5f))
                    ElevatedButton(
                        elevation = ButtonDefaults.buttonElevation(
                            defaultElevation = 2.dp
                        ),
                        shape = RoundedCornerShape(8.dp),
                        contentPadding = PaddingValues(0.dp),
                        modifier = Modifier
                            .weight(0.9f)
                            .defaultMinSize(minWidth = 1.dp, minHeight = 30.dp),
                        onClick = { /*TODO*/ }
                    ) {
                        Icon(
                            Icons.Filled.FilterAlt,
                            contentDescription = "Localized description",
                        )
                    }
                    Spacer(modifier = Modifier.weight(0.5f))
                    ElevatedButton(
                        elevation = ButtonDefaults.buttonElevation(
                            defaultElevation = 2.dp
                        ),
                        shape = RoundedCornerShape(8.dp),
                        contentPadding = PaddingValues(0.dp),
                        modifier = Modifier
                            .weight(0.9f)
                            .defaultMinSize(minWidth = 1.dp, minHeight = 30.dp),
                        onClick = { /*TODO*/ }
                    ) {
                        Icon(
                            Icons.AutoMirrored.Filled.Sort,
                            contentDescription = "Localized description",
                        )
                    }
                }
                Spacer(modifier = Modifier.weight(0.2f))
                LazyColumn(
                    contentPadding = PaddingValues(bottom = 10.dp, top = 10.dp),
                    verticalArrangement = Arrangement.spacedBy(32.dp),
                    modifier = Modifier.weight(10f)
                ) {
                    items(
                        items = state.tasks,
                        key = { it.id }
                    ) { task ->
                        SwipeToDeleteContainer(
                            item = task,
                            onDelete = {
                                onEvent(TaskEvent.DeleteTask(task.id))
                            }
                        ) {
                            TaskCard(
                                taskCardDto = task,
                                onCheckedChange = { id, done -> onEvent(TaskEvent.SetDone(id, done)) }
                            )
                        }
                    }
                }
            }
        },
        bottomBar = {
            BottomAppBar(
                modifier = Modifier.graphicsLayer { shadowElevation = 80f },
                containerColor = Color(0xFFFFFFFF),
                contentColor = Color(0xFFA0A0A0),
                actions = {
                    IconButton(onClick = { /* do something */ }) {
                        Icon(
                            Icons.Outlined.Settings,
                            contentDescription = "Localized description"
                        )
                    }
                    IconButton(onClick = { /* do something */ }) {
                        Icon(
                            Icons.Outlined.Search,
                            contentDescription = "Localized description",
                        )
                    }
                },
                floatingActionButton = {
                    FloatingActionButton(
                        onClick = {
                            onEvent(TaskEvent.ShowDialog)
                        },
                        containerColor = Color(0xFF2984BA),
                        contentColor = Color(0xFFFFFFFF),
                        elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation()
                    ) {
                        Icon(Icons.Filled.Add, "Localized description")
                    }
                }
            )
        }
    )
}

TaskCard.kt

package com.pochopsp.dailytasks.presentation.tasks

import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.pochopsp.dailytasks.R
import com.pochopsp.dailytasks.data.database.TaskCardDto
import com.pochopsp.dailytasks.presentation.theme.Constants

@Composable
fun TaskCard(taskCardDto: TaskCardDto, onCheckedChange: (Int, Boolean) -> Unit) {

    ElevatedCard(
        elevation = CardDefaults.cardElevation(
            defaultElevation = 6.dp
        ),
        modifier = Modifier
            .height(70.dp)
            .fillMaxWidth(),
        shape = Constants.cardShape
    ) {
        Row(
            modifier = Modifier
                .padding(vertical = 12.dp, horizontal = 20.dp)
                .fillMaxWidth()
                .fillMaxHeight(),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.SpaceBetween
        ) {
            Image(
                painter = painterResource(id = R.drawable.postit),
                contentDescription = "default task icon",
                modifier = Modifier.weight(0.7f)
            )
            Text(
                text = taskCardDto.title,
                fontWeight = FontWeight.Medium,
                style = if (taskCardDto.done) {
                    LocalTextStyle.current.copy(textDecoration = TextDecoration.LineThrough)
                } else LocalTextStyle.current.copy(),
                modifier = Modifier
                    .weight(4f)
                    .padding(horizontal = 20.dp)
            )
            Box (
                modifier = Modifier
                    .background(Color.White)
                    .weight(0.5f)
                    .aspectRatio(1f)
            )
            {
                Checkbox(
                    checked = taskCardDto.done,
                    onCheckedChange = { isChecked -> onCheckedChange(taskCardDto.id, isChecked) },
                    modifier = Modifier.scale(1.5f),
                    colors = CheckboxDefaults.colors(
                        checkedColor = Color(0xFF2984BA),
                        uncheckedColor = Color(0xFF2984BA),
                        checkmarkColor = Color.White,
                        )
                )
            }
        }
    }
}

SwipeToDeleteContainer.kt

package com.pochopsp.dailytasks.presentation.common

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.DismissDirection
import androidx.compose.material3.DismissState
import androidx.compose.material3.DismissValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.SwipeToDismiss
import androidx.compose.material3.rememberDismissState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.pochopsp.dailytasks.presentation.theme.Constants
import kotlinx.coroutines.delay

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun <T> SwipeToDeleteContainer(
    item: T,
    onDelete: (T) -> Unit,
    animationDuration: Int = 500,
    content: @Composable (T) -> Unit
) {
    var isRemoved by remember {
        mutableStateOf(false)
    }
    val state = rememberDismissState(
        confirmValueChange = { value ->
            if (value == DismissValue.DismissedToStart) {
                isRemoved = true
                true
            } else {
                false
            }
        }
    )

    LaunchedEffect(key1 = isRemoved) {
        if(isRemoved) {
            delay(animationDuration.toLong())
            onDelete(item)
        }
    }

    AnimatedVisibility(
        visible = !isRemoved,
        exit = shrinkVertically(
            animationSpec = tween(durationMillis = animationDuration),
            shrinkTowards = Alignment.Top
        ) + fadeOut()
    ) {
        SwipeToDismiss(
            state = state,
            background = {
                DeleteBackground(swipeDismissState = state)
            },
            dismissContent = { content(item) },
            directions = setOf(DismissDirection.EndToStart)
        )
    }
}



@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DeleteBackground(
    swipeDismissState: DismissState
) {
    val color = if (swipeDismissState.dismissDirection == DismissDirection.EndToStart) {
        Color.Red
    } else Color.Transparent

    Box(
        modifier = Modifier
            .clip(Constants.cardShape)
            .background(color)
            .padding(16.dp)
            .fillMaxSize(),
        contentAlignment = Alignment.CenterEnd
    ) {
        Icon(
            imageVector = Icons.Default.Delete,
            contentDescription = null,
            tint = Color.White
        )
    }
}

Constants.kt

package com.pochopsp.dailytasks.presentation.theme

import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.unit.dp

object Constants {
    val cardShape = RoundedCornerShape(size = 9.dp)
}

Solution

  • I've managed to solve the problem by removing the AnimatedVisibility that wrapped the SwipeToDismiss composable (so no animation there) and instead wrapping the LazyColumn items' itemContent with a Box composable with modifier = Modifier.animateItemPlacement().

    So my code now looks like this:

    (TaskCard.kt and Constants.kt are unchanged)

    MainScreen.kt

    package com.pochopsp.dailytasks.presentation.screen
    
    import androidx.compose.foundation.ExperimentalFoundationApi
    import androidx.compose.foundation.layout.Arrangement
    import androidx.compose.foundation.layout.Box
    import androidx.compose.foundation.layout.Column
    import androidx.compose.foundation.layout.PaddingValues
    import androidx.compose.foundation.layout.Row
    import androidx.compose.foundation.layout.Spacer
    import androidx.compose.foundation.layout.defaultMinSize
    import androidx.compose.foundation.layout.padding
    import androidx.compose.foundation.lazy.LazyColumn
    import androidx.compose.foundation.lazy.items
    import androidx.compose.foundation.shape.RoundedCornerShape
    import androidx.compose.material.icons.Icons
    import androidx.compose.material.icons.automirrored.filled.Sort
    import androidx.compose.material.icons.filled.Add
    import androidx.compose.material.icons.filled.FilterAlt
    import androidx.compose.material.icons.outlined.Search
    import androidx.compose.material.icons.outlined.Settings
    import androidx.compose.material3.BottomAppBar
    import androidx.compose.material3.ButtonDefaults
    import androidx.compose.material3.ElevatedButton
    import androidx.compose.material3.FloatingActionButton
    import androidx.compose.material3.FloatingActionButtonDefaults
    import androidx.compose.material3.Icon
    import androidx.compose.material3.IconButton
    import androidx.compose.material3.Scaffold
    import androidx.compose.material3.Text
    import androidx.compose.runtime.Composable
    import androidx.compose.ui.Alignment
    import androidx.compose.ui.Modifier
    import androidx.compose.ui.graphics.Color
    import androidx.compose.ui.graphics.graphicsLayer
    import androidx.compose.ui.res.stringResource
    import androidx.compose.ui.text.font.FontWeight
    import androidx.compose.ui.unit.dp
    import com.pochopsp.dailytasks.R
    import com.pochopsp.dailytasks.data.database.TaskEvent
    import com.pochopsp.dailytasks.data.database.TaskState
    import com.pochopsp.dailytasks.presentation.AddTaskDialog
    import com.pochopsp.dailytasks.presentation.common.SwipeToDeleteContainer
    import com.pochopsp.dailytasks.presentation.tasks.TaskCard
    
    @OptIn(ExperimentalFoundationApi::class)
    @Composable
    fun MainScreen(
        state: TaskState,
        onEvent: (TaskEvent) -> Unit
    ) {
    
        Scaffold(
            modifier = Modifier,
            content = { paddingValues ->
    
                if(state.isAddingTask){
                    AddTaskDialog(state = state, onEvent = onEvent)
                }
    
                Column(
                    modifier = Modifier.padding(start = 30.dp, end = 30.dp,
                        bottom = paddingValues.calculateBottomPadding(),
                        top = paddingValues.calculateTopPadding())
                ) {
                    Row (
                        verticalAlignment = Alignment.CenterVertically,
                        modifier = Modifier.weight(1f)
                    ){
                        Text(
                            text = stringResource(R.string.done_tasks_count,
                                state.tasks.filter { t -> t.done }.size, state.tasks.size),
                            fontWeight = FontWeight.SemiBold,
                            modifier = Modifier.weight(2f)
                        )
                        Spacer(modifier = Modifier.weight(1.5f))
                        ElevatedButton(
                            elevation = ButtonDefaults.buttonElevation(
                                defaultElevation = 2.dp
                            ),
                            shape = RoundedCornerShape(8.dp),
                            contentPadding = PaddingValues(0.dp),
                            modifier = Modifier
                                .weight(0.9f)
                                .defaultMinSize(minWidth = 1.dp, minHeight = 30.dp),
                            onClick = { /*TODO*/ }
                        ) {
                            Icon(
                                Icons.Filled.FilterAlt,
                                contentDescription = "Localized description",
                            )
                        }
                        Spacer(modifier = Modifier.weight(0.5f))
                        ElevatedButton(
                            elevation = ButtonDefaults.buttonElevation(
                                defaultElevation = 2.dp
                            ),
                            shape = RoundedCornerShape(8.dp),
                            contentPadding = PaddingValues(0.dp),
                            modifier = Modifier
                                .weight(0.9f)
                                .defaultMinSize(minWidth = 1.dp, minHeight = 30.dp),
                            onClick = { /*TODO*/ }
                        ) {
                            Icon(
                                Icons.AutoMirrored.Filled.Sort,
                                contentDescription = "Localized description",
                            )
                        }
                    }
                    Spacer(modifier = Modifier.weight(0.2f))
                    LazyColumn(
                        contentPadding = PaddingValues(bottom = 10.dp, top = 10.dp),
                        verticalArrangement = Arrangement.spacedBy(32.dp),
                        modifier = Modifier.weight(10f)
                    ) {
                        items(
                            items = state.tasks,
                            key = { it.id }
                        ) { task ->
                            Box(modifier = Modifier.animateItemPlacement()){
                                SwipeToDeleteContainer(
                                    item = task,
                                    onDelete = {
                                        onEvent(TaskEvent.DeleteTask(task.id))
                                    }
                                ) {
                                    TaskCard(
                                        taskCardDto = task,
                                        onCheckedChange = { id, done -> onEvent(TaskEvent.SetDone(id, done)) }
                                    )
                                }
                            }
                        }
                    }
                }
            },
            bottomBar = {
                BottomAppBar(
                    modifier = Modifier.graphicsLayer { shadowElevation = 80f },
                    containerColor = Color(0xFFFFFFFF),
                    contentColor = Color(0xFFA0A0A0),
                    actions = {
                        IconButton(onClick = { /* do something */ }) {
                            Icon(
                                Icons.Outlined.Settings,
                                contentDescription = "Localized description"
                            )
                        }
                        IconButton(onClick = { /* do something */ }) {
                            Icon(
                                Icons.Outlined.Search,
                                contentDescription = "Localized description",
                            )
                        }
                    },
                    floatingActionButton = {
                        FloatingActionButton(
                            onClick = {
                                onEvent(TaskEvent.ShowDialog)
                            },
                            containerColor = Color(0xFF2984BA),
                            contentColor = Color(0xFFFFFFFF),
                            elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation()
                        ) {
                            Icon(Icons.Filled.Add, "Localized description")
                        }
                    }
                )
            }
        )
    }
    

    SwipeToDeleteContainer.kt

    package com.pochopsp.dailytasks.presentation.common
    
    import androidx.compose.animation.AnimatedVisibility
    import androidx.compose.animation.core.tween
    import androidx.compose.animation.fadeOut
    import androidx.compose.animation.shrinkVertically
    import androidx.compose.foundation.background
    import androidx.compose.foundation.layout.Box
    import androidx.compose.foundation.layout.fillMaxSize
    import androidx.compose.foundation.layout.padding
    import androidx.compose.material.icons.Icons
    import androidx.compose.material.icons.filled.Delete
    import androidx.compose.material3.DismissDirection
    import androidx.compose.material3.DismissState
    import androidx.compose.material3.DismissValue
    import androidx.compose.material3.ExperimentalMaterial3Api
    import androidx.compose.material3.Icon
    import androidx.compose.material3.SwipeToDismiss
    import androidx.compose.material3.rememberDismissState
    import androidx.compose.runtime.Composable
    import androidx.compose.runtime.LaunchedEffect
    import androidx.compose.runtime.getValue
    import androidx.compose.runtime.mutableStateOf
    import androidx.compose.runtime.remember
    import androidx.compose.runtime.setValue
    import androidx.compose.ui.Alignment
    import androidx.compose.ui.Modifier
    import androidx.compose.ui.draw.clip
    import androidx.compose.ui.graphics.Color
    import androidx.compose.ui.unit.dp
    import com.pochopsp.dailytasks.presentation.theme.Constants
    import kotlinx.coroutines.delay
    
    @OptIn(ExperimentalMaterial3Api::class)
    @Composable
    fun <T> SwipeToDeleteContainer(
        item: T,
        onDelete: (T) -> Unit,
        animationDuration: Int = 500,
        content: @Composable (T) -> Unit
    ) {
        var isRemoved by remember {
            mutableStateOf(false)
        }
        val state = rememberDismissState(
            confirmValueChange = { value ->
                if (value == DismissValue.DismissedToStart) {
                    isRemoved = true
                    true
                } else {
                    false
                }
            }
        )
    
        LaunchedEffect(key1 = isRemoved) {
            if(isRemoved) {
                onDelete(item)
            }
        }
    
        SwipeToDismiss(
            state = state,
            background = {
                DeleteBackground(swipeDismissState = state)
            },
            dismissContent = { content(item) },
            directions = setOf(DismissDirection.EndToStart)
        )
    }
    
    
    
    @OptIn(ExperimentalMaterial3Api::class)
    @Composable
    private fun DeleteBackground(
        swipeDismissState: DismissState
    ) {
        val color = if (swipeDismissState.dismissDirection == DismissDirection.EndToStart) {
            Color.Red
        } else Color.Transparent
    
        Box(
            modifier = Modifier
                .clip(Constants.cardShape)
                .background(color)
                .padding(16.dp)
                .fillMaxSize(),
            contentAlignment = Alignment.CenterEnd
        ) {
            Icon(
                imageVector = Icons.Default.Delete,
                contentDescription = null,
                tint = Color.White
            )
        }
    }
    

    Here's the fixed animation in a GIF: