As suggested in Jan Itor's answer setting delay time a bit longer than exit animation lets enter animations run with specified easing. Otherwise enter animations still run but faster as in gif below.
[![enter image description here][1]][1]
With this snippet
@Composable
private fun StaggeredList(
filter: String,
itemList: List<SomeData>,
) {
BoxWithConstraints(
modifier = Modifier
) {
val itemWidth = (maxWidth - 8.dp) / 2
val heightList by remember {
mutableStateOf(
List(10) { index ->
if (index == 0) {
200.dp
} else Random.nextInt(120, 200).dp
}
)
}
FlowRow(
modifier = Modifier,
maxItemsInEachRow = 2,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
itemList.forEachIndexed { index, it ->
key(it.id) {
var visible by remember {
mutableStateOf(false)
}
LaunchedEffect(filter) {
visible = false
delay(1500)
visible = true
}
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(1500)) +
slideInVertically(tween(1500)) {
it / 2
},
exit = fadeOut(tween(1500))
+ slideOutVertically(tween(1500)) {
it / 2
}
) {
MyRow(
modifier = Modifier.size(itemWidth, heightList[index]),
item = it
)
}
}
}
}
}
}
But setting a delay longer in composable causes scrollable LazyColumn to be resized and items to be displayed below. To solve this i retrieved FlowRow height before and set it as height. Also i decided using keys that not only unique to data but also to filtering as well with `key(it.id + filter)`
@Composable
private fun StaggeredList(
filter: String,
itemList: List<SomeData>,
) {
BoxWithConstraints(
modifier = Modifier
) {
val itemWidth = (maxWidth - 8.dp) / 2
val heightList by remember {
mutableStateOf(
List(10) { index ->
if (index == 0) {
200.dp
} else Random.nextInt(120, 200).dp
}
)
}
var height by remember {
mutableStateOf(0.dp)
}
val density = LocalDensity.current
FlowRow(
modifier = Modifier
.onGloballyPositioned {
val newHeight = it.size.height
if (newHeight != 0) {
height = with(density) {
newHeight.toDp()
}
}
}
.heightIn(min = height)
.border(2.dp, Color.Green),
maxItemsInEachRow = 2,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
itemList.forEachIndexed { index, it ->
// 🔥 These keys are unique for items for every filtering
// without filter if items exist from previous filtering
// they don't leave recomposition
key(it.id + filter) {
var visible by remember {
mutableStateOf(false)
}
LaunchedEffect(filter) {
visible = false
delay(250)
visible = true
height = 0.dp
}
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(250)) +
slideInVertically(tween(250)) {
it / 2
},
exit = fadeOut(tween(250)) + slideOutVertically(tween(250)) {
it / 2
}
) {
MyRow(
modifier = Modifier.size(itemWidth, heightList[index]),
item = it
)
}
}
}
}
}
}
In this case by removing items from composition with filter unique keys i also forfeited all of the exit animations instead of in other case only items that are recomposed having exit animations while items that don't exist in new filter were removed.
[![enter image description here][2]][2]
[1]: https://i.sstatic.net/JpXPtJB2.gif
[2]: https://i.sstatic.net/xVtarUXi.gif
I solved this in 2 ways but first way is not i preferred for my implementation because it removes every item from composition and in a scrollable list/LazyColumn desired animation runs below visible area.
class SomeViewModel : ViewModel() {
private val list = listOf(
SomeData(id = "1", value = "Row1"),
SomeData(id = "2", value = "Row2"),
SomeData(id = "3", value = "Row3"),
SomeData(id = "4", value = "Row4"),
SomeData(id = "5", value = "Row5")
)
var itemList by mutableStateOf(list)
var filter: Int = 0
fun filter() {
if (filter % 3 == 0) {
itemList = listOf(
list[0],
list[1],
list[2]
)
} else if (filter % 3 == 1) {
itemList = listOf(
list[1],
list[2]
)
} else {
itemList = listOf(
list[0],
list[2],
list[3]
)
}
filter++
}
}
data class SomeData(val id: String, val value: String)
pass this `filter` in ViewModel to Composable that contains `FlowRow`
@Composable
fun MyComposable(someViewModel: SomeViewModel) {
Column(modifier = Modifier.fillMaxSize().background(backgroundColor)) {
val itemList = someViewModel.itemList
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(16.dp)
) {
items(5) {
Box(
modifier = Modifier
.background(Color.Red, RoundedCornerShape(16.dp))
.fillMaxWidth().height(100.dp)
)
}
item {
Button(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
onClick = {
someViewModel.filter()
}
) {
Text("Filter")
}
}
item {
StaggeredList(
filter = someViewModel.filter.toString(),
itemList = itemList
)
}
}
}
}
And in this composable `key(it.id + filter)` to remove every item from composition when filter changes but this approach makes items below visible area since every item is removed and added to recomposition in a LazyColumn.
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun StaggeredList(
filter: String,
itemList: List<SomeData>,
) {
BoxWithConstraints(
modifier = Modifier
) {
val itemWidth = (maxWidth - 8.dp) / 2
FlowRow(
modifier = Modifier.fillMaxSize(),
maxItemsInEachRow = 2,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
itemList.forEachIndexed { index, it ->
key(it.id + filter) {
var visible by remember {
mutableStateOf(false)
}
LaunchedEffect(Unit) {
visible = false
println("Composing ${it.id},")
delay(250)
visible = true
}
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(250)) +
slideInVertically(tween(250)) {
it / 2
},
exit = fadeOut(tween(250)) + slideOutVertically(tween(250)) {
it / 2
}
) {
MyRow(
modifier = Modifier.size(itemWidth, 200.dp),
item = it
)
}
}
}
}
}
}
In second approach i used same key, but using `filter` as `LaunchedEffect` key to restart animation but this time fadeIn() and fadeOut animations stopped working.
key(it.id) {
var visible by remember {
mutableStateOf(false)
}
LaunchedEffect(filter) {
visible = false
println("Composing ${it.id},")
delay(250)
visible = true
}
}
And Row composable is
@Composable
fun MyRow(
modifier: Modifier = Modifier,
item: SomeData,
) {
var counter by remember {
mutableIntStateOf(0)
}
Column(
modifier = modifier
.shadow(2.dp, RoundedCornerShape(16.dp))
.background(Color.White, RoundedCornerShape(16.dp))
.padding(16.dp)
) {
Text(
"id: ${item.id}, value: ${item.value}"
)
Spacer(modifier = Modifier.weight(1f))
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
counter++
}
) {
Text("Counter: $counter")
}
}
}