androidanimationandroid-jetpack-composeandroid-animation

How to make animation on LazyGrid for Item perform only first time appearence?


For some reason there are 2 recompostions happens which results to unable to set a condition for the item to check if it has been already presented to a user.

I would like to make such an animation for LazyGrid (I tried to optimize my code a little bit, but the meaning the same) - https://yasinkacmaz.medium.com/simple-item-animation-with-jetpack-composes-lazygrid-78316992af22 Make an items to appear like bubble effect

There is my code:

private val dataSet: List<String> = listOf("Item 1", "Item 2", "Item 3", "Item 4", "Item 5")
private val data: List<String> = List(5) { dataSet }.flatten()

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            Test_delete_itTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    Gallery(
                        paddingValues = innerPadding,
                        uiConfig = { data }
                    )
                }
            }
        }
    }
}

@Composable
private fun Gallery(
    paddingValues: PaddingValues,
    uiConfig: () -> List<String>
) {
    val config: List<String> = uiConfig()
    val columns = 2

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(paddingValues)
    ) {
        LazyVerticalGrid(
            columns = GridCells.Fixed(columns),
            modifier = Modifier.fillMaxSize(),
            verticalArrangement = Arrangement.spacedBy(8.dp),
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            content = {
                items(config.size) { idx ->
                    val item: String = config[idx]
                    val (scale, alpha) = scaleAndAlpha(idx, columns)

                    MyItem(
                        modifier = Modifier.graphicsLayer(alpha = alpha, scaleX = scale, scaleY = scale),
                        text = item
                    )
                }
            }
        )
    }
}

@Composable
private fun MyItem(
    modifier: Modifier = Modifier,
    text: String
) {
    Card(
        modifier = modifier.height(150.dp),
        shape = RoundedCornerShape(16.dp),
        elevation = CardDefaults.cardElevation(8.dp),
        colors = CardDefaults.cardColors(
            containerColor = Color.Blue,
        )
    ) {
        Box(
            modifier = Modifier
                .weight(1f)
                .height(150.dp)
                .clip(RoundedCornerShape(16.dp))
        ) {
            Text(
                text = text,
                color = Color.White
            )
        }
    }
}

@Immutable
private enum class State { PLACING, PLACED }

@Immutable
data class ScaleAndAlphaArgs(
    val fromScale: Float,
    val toScale: Float,
    val fromAlpha: Float,
    val toAlpha: Float
)

@OptIn(ExperimentalTransitionApi::class)
@Composable
fun scaleAndAlpha(
    args: ScaleAndAlphaArgs,
    animation: FiniteAnimationSpec<Float>
): Pair<Float, Float> {
    val transitionState = remember { MutableTransitionState(State.PLACING).apply { targetState = State.PLACED } }
    val transition = rememberTransition(transitionState, label = "")
    val alpha by transition.animateFloat(transitionSpec = { animation }, label = "") {
        if (it == State.PLACING) args.fromAlpha else args.toAlpha
    }
    val scale by transition.animateFloat(transitionSpec = { animation }, label = "") {
        if (it == State.PLACING) args.fromScale else args.toScale
    }
    return alpha to scale
}

val scaleAndAlpha: @Composable (idx: Int, columns: Int) -> Pair<Float, Float> = { idx, columns ->
    scaleAndAlpha(
        args = ScaleAndAlphaArgs(2f, 1f, 0f, 1f),
        animation = tween(300, delayMillis = (idx / columns) * 100)
    )
}

I tried to add a condition for the first time presented:

@Composable
private fun Gallery(
    paddingValues: PaddingValues,
    uiConfig: () -> List<String>
) {
    val config: List<String> = uiConfig()
    val columns = 2

    // Remember a set of already animated indices
    val animatedIndices = remember { mutableSetOf<Int>() }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(paddingValues)
    ) {
        LazyVerticalGrid(
            columns = GridCells.Fixed(columns),
            modifier = Modifier.fillMaxSize(),
            verticalArrangement = Arrangement.spacedBy(8.dp),
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            content = {
                items(config.size) { idx ->
                    val item: String = config[idx]

                    // Determine if the item should animate
                    val shouldAnimate = !animatedIndices.contains(idx)

                    // If it should animate, mark it as animated
                    if (shouldAnimate) {
                        animatedIndices.add(idx)
                    }

                    val (scale, alpha) = if (shouldAnimate) {
                        scaleAndAlpha(idx, columns)
                    } else {
                        1f to 1f // No animation
                    }

                    MyItem(
                        modifier = Modifier.graphicsLayer(alpha = alpha, scaleX = scale, scaleY = scale),
                        text = item
                    )
                }
            }
        )
    }
}

But the issue is that recomposition happens twice here - items(config.size) { idx -> that makes the condition useless.

What am I missing here?


Solution

  • On the first composition of the items lambda, when shouldAnimate is true, scaleAndAlpha is called. That is a compose function that is recomposed on each frame of the animation. It also returns the current values for scale and alpha on each recomposition. In order for MyItem to update accordingly, the entire items lambda is recomposed when scale and alpha change.

    This is the second composition you do not want to have because now shouldAnimate is set to false and the animation that was just started is skipped entirely.

    A simple fix would be to extract scaleAndAlpha and MyItem into a dedicated composable so its recompositions are independent of shouldAnimate:

    @Composable
    private fun MyAnimatedItem(
        shouldAnimate: Boolean,
        idx: Int,
        columns: Int,
        item: String,
    ) {
        val (scale, alpha) = if (shouldAnimate) {
            scaleAndAlpha(idx, columns)
        } else {
            1f to 1f // No animation
        }
    
        MyItem(
            modifier = Modifier.graphicsLayer(
                alpha = alpha,
                scaleX = scale,
                scaleY = scale,
            ),
            text = item,
        )
    }
    

    Simply called like this (in addition I simplified it to use itemsIndexed instead of items):

    itemsIndexed(config) { idx, item ->
        // Determine if the item should animate
        val shouldAnimate = !animatedIndices.contains(idx)
    
        // If it should animate, mark it as animated
        if (shouldAnimate) {
            animatedIndices.add(idx)
        }
    
        MyAnimatedItem(shouldAnimate, idx, columns, item)
    }
    

    Now the recompositions that occur due to the animation are limited to MyAnimatedItem, the itemsIndexed lambda is not affected and is only recomposed when the item was scrolled out of the viewport and in again. And only then shouldAnimate is set to false, as intended.