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