Item appearance animation for lazy lists (not public api)

This change adds a currently internal api for animating item appearance in lazy lists only for now. We will make them public after we have every needed part merged for the complete solution.

Test: LazyListItemAppearanceAnimationTest
Change-Id: Ifcacdc15667613e504345284465a59ba9ac4ab7c
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListItemAppearanceAnimationTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListItemAppearanceAnimationTest.kt
new file mode 100644
index 0000000..d323292
--- /dev/null
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListItemAppearanceAnimationTest.kt
@@ -0,0 +1,401 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.foundation.lazy.list
+
+import android.os.Build
+import androidx.compose.animation.core.FiniteAnimationSpec
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.scrollBy
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.requiredHeight
+import androidx.compose.foundation.layout.requiredWidth
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListScope
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.animateItem
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.testutils.assertPixels
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.compositeOver
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.assertHeightIsEqualTo
+import androidx.compose.ui.test.captureToImage
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntSize
+import androidx.test.filters.LargeTest
+import androidx.test.filters.SdkSuppress
+import com.google.common.truth.Truth.assertThat
+import kotlin.math.abs
+import kotlinx.coroutines.runBlocking
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+@LargeTest
+@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+@OptIn(ExperimentalFoundationApi::class)
+class LazyListItemAppearanceAnimationTest {
+
+    @get:Rule
+    val rule = createComposeRule()
+
+    private val itemSize: Int = 4
+    private var itemSizeDp: Dp = Dp.Infinity
+    private val crossAxisSize: Int = 2
+    private var crossAxisSizeDp: Dp = Dp.Infinity
+    private val containerSize: Float = itemSize * 2f
+    private var containerSizeDp: Dp = Dp.Infinity
+    private lateinit var state: LazyListState
+
+    @Before
+    fun before() {
+        rule.mainClock.autoAdvance = false
+        with(rule.density) {
+            itemSizeDp = itemSize.toDp()
+            crossAxisSizeDp = crossAxisSize.toDp()
+            containerSizeDp = containerSize.toDp()
+        }
+    }
+
+    @Test
+    fun oneItemAdded() {
+        var list by mutableStateOf(emptyList<Color>())
+        rule.setContent {
+            LazyList(containerSize = itemSizeDp) {
+                items(list, key = { it.toArgb() }) {
+                    Item(it)
+                }
+            }
+        }
+
+        rule.runOnUiThread {
+            list = listOf(Color.Black)
+        }
+
+        onAnimationFrame { fraction ->
+            assertPixels(mainAxisSize = itemSize) {
+                Color.Black.copy(alpha = fraction)
+            }
+        }
+    }
+
+    @Test
+    fun noAnimationForInitialList() {
+        rule.setContent {
+            LazyList(containerSize = itemSizeDp) {
+                items(listOf(Color.Black), key = { it.toArgb() }) {
+                    Item(it)
+                }
+            }
+        }
+
+        assertPixels(itemSize) {
+            Color.Black
+        }
+    }
+
+    @Test
+    fun oneExistTwoAdded() {
+        var list by mutableStateOf(listOf(Color.Black))
+        rule.setContent {
+            LazyList(containerSize = itemSizeDp * 3) {
+                items(list, key = { it.toArgb() }) {
+                    Item(it)
+                }
+            }
+        }
+
+        rule.runOnUiThread {
+            list = listOf(Color.Black, Color.Red, Color.Green)
+        }
+
+        onAnimationFrame { fraction ->
+            assertPixels(itemSize * 3) { offset ->
+                when (offset) {
+                    in 0 until itemSize -> Color.Black
+                    in itemSize until itemSize * 2 -> Color.Red.copy(alpha = fraction)
+                    else -> Color.Green.copy(alpha = fraction)
+                }
+            }
+        }
+    }
+
+    @Test
+    fun onlyItemWithSpecsIsAnimating() {
+        var list by mutableStateOf(emptyList<Color>())
+        rule.setContent {
+            LazyList(containerSize = itemSizeDp * 2) {
+                items(list, key = { it.toArgb() }) {
+                    Item(it, animSpec = if (it == Color.Red) AnimSpec else null)
+                }
+            }
+        }
+
+        rule.runOnUiThread {
+            list = listOf(Color.Black, Color.Red)
+        }
+
+        onAnimationFrame { fraction ->
+            assertPixels(itemSize * 2) { offset ->
+                when (offset) {
+                    in 0 until itemSize -> Color.Black
+                    else -> Color.Red.copy(alpha = fraction)
+                }
+            }
+        }
+    }
+
+    @Test
+    fun itemAddedOutsideOfViewportIsNotAnimated() {
+        var list by mutableStateOf(listOf(Color.Black, Color.Red, Color.Green))
+        rule.setContent {
+            LazyList(containerSize = itemSizeDp * 2) {
+                items(list, key = { it.toArgb() }) {
+                    Item(it)
+                }
+            }
+        }
+
+        rule.runOnUiThread {
+            // Blue is added before Green, both are outside the bounds
+            list = listOf(Color.Black, Color.Red, Color.Blue, Color.Green)
+        }
+
+        rule.runOnIdle {
+            runBlocking {
+                // scroll 1.5 items so we now see half of Red, Blue and half of Green
+                state.scrollBy(itemSize * 1.5f)
+            }
+        }
+
+        onAnimationFrame {
+            assertPixels(itemSize * 2) { offset ->
+                when (offset) {
+                    in 0 until itemSize / 2 -> Color.Red
+                    in itemSize / 2 until itemSize * 3 / 2 -> Color.Blue
+                    else -> Color.Green
+                }
+            }
+        }
+    }
+
+    @Test
+    fun animatedItemChangesTheContainerSize() {
+        var list by mutableStateOf(listOf(Color.Black))
+        rule.setContent {
+            LazyList(containerSize = null) {
+                items(list, key = { it.toArgb() }) {
+                    Item(it)
+                }
+            }
+        }
+
+        rule.onNodeWithTag(ContainerTag)
+            .assertHeightIsEqualTo(itemSizeDp)
+
+        rule.runOnUiThread {
+            list = listOf(Color.Black, Color.Red)
+        }
+
+        onAnimationFrame {
+            rule.onNodeWithTag(ContainerTag)
+                .assertHeightIsEqualTo(itemSizeDp * 2)
+        }
+    }
+
+    @Test
+    fun removeItemBeingAnimated() {
+        var list by mutableStateOf(emptyList<Color>())
+        rule.setContent {
+            LazyList(containerSize = itemSizeDp) {
+                items(list, key = { it.toArgb() }) {
+                    Item(it)
+                }
+            }
+        }
+
+        rule.runOnUiThread {
+            list = listOf(Color.Black)
+        }
+
+        onAnimationFrame { fraction ->
+            if (fraction < 0.5f) {
+                assertPixels(itemSize) { Color.Black.copy(alpha = fraction) }
+            } else {
+                if (fraction.isCloseTo(0.5f)) {
+                    rule.runOnUiThread {
+                        list = emptyList()
+                    }
+                }
+                assertPixels(itemSize) { Color.Transparent }
+            }
+        }
+    }
+
+    @Test
+    fun scrollAwayFromAnimatedItem() {
+        var list by mutableStateOf(listOf(Color.Black, Color.Green, Color.Blue, Color.Yellow))
+        rule.setContent {
+            LazyList(containerSize = itemSizeDp * 2) {
+                items(list, key = { it.toArgb() }) {
+                    Item(it)
+                }
+            }
+        }
+
+        rule.runOnUiThread {
+            // item at position 1 is new
+            list = listOf(Color.Black, Color.Red, Color.Green, Color.Blue, Color.Yellow)
+        }
+
+        onAnimationFrame { fraction ->
+            if (fraction < 0.35f) {
+                assertPixels(itemSize * 2) { offset ->
+                    when (offset) {
+                        in 0 until itemSize -> Color.Black
+                        else -> Color.Red.copy(alpha = fraction)
+                    }
+                }
+            } else if (fraction.isCloseTo(0.5f)) {
+                rule.runOnUiThread {
+                    runBlocking { state.scrollBy(itemSize * 2f) }
+                    runBlocking { state.scrollBy(itemSize * 0.5f) }
+                }
+                assertPixels(itemSize * 2) { offset ->
+                    // red item is not displayed anywhere
+                    when (offset) {
+                        in 0 until itemSize / 2 -> Color.Green
+                        in itemSize / 2 until itemSize * 3 / 2 -> Color.Blue
+                        else -> Color.Yellow
+                    }
+                }
+            } else {
+                if (fraction.isCloseTo(0.75f)) {
+                    rule.runOnUiThread {
+                        runBlocking {
+                            state.scrollBy(-itemSize * 1.5f)
+                        }
+                    }
+                }
+                assertPixels(itemSize * 2) { offset ->
+                    // red item is not displayed anywhere
+                    when (offset) {
+                        // the animation should be canceled so the red item has no alpha
+                        in 0 until itemSize -> Color.Red
+                        else -> Color.Green
+                    }
+                }
+            }
+        }
+    }
+
+    private fun assertPixels(
+        mainAxisSize: Int,
+        crossAxisSize: Int = this.crossAxisSize,
+        expectedColorProvider: (offset: Int) -> Color?
+    ) {
+        rule.onNodeWithTag(ContainerTag)
+            .captureToImage()
+            .assertPixels(IntSize(crossAxisSize, mainAxisSize)) {
+                expectedColorProvider(it.y)?.compositeOver(Color.White)
+            }
+    }
+
+    private fun onAnimationFrame(duration: Long = Duration, onFrame: (fraction: Float) -> Unit) {
+        require(duration.mod(FrameDuration) == 0L)
+        rule.waitForIdle()
+        rule.mainClock.advanceTimeByFrame()
+        var expectedTime = rule.mainClock.currentTime
+        for (i in 0..duration step FrameDuration) {
+            val fraction = i / duration.toFloat()
+            onFrame(fraction)
+            if (i < duration) {
+                rule.mainClock.advanceTimeBy(FrameDuration)
+                expectedTime += FrameDuration
+                assertThat(expectedTime).isEqualTo(rule.mainClock.currentTime)
+            }
+        }
+    }
+
+    @Composable
+    private fun LazyList(
+        containerSize: Dp? = containerSizeDp,
+        startIndex: Int = 0,
+        crossAxisSize: Dp = crossAxisSizeDp,
+        content: LazyListScope.() -> Unit
+    ) {
+        state = rememberLazyListState(startIndex)
+
+        LazyColumn(
+            state = state,
+            modifier = Modifier
+                .then(
+                    if (containerSize != null) {
+                        Modifier.requiredHeight(containerSize)
+                    } else {
+                        Modifier
+                    }
+                )
+                .background(Color.White)
+                .then(
+                    if (crossAxisSize != Dp.Unspecified) {
+                        Modifier.requiredWidth(crossAxisSize)
+                    } else {
+                        Modifier.fillMaxWidth()
+                    }
+                )
+                .testTag(ContainerTag),
+            content = content
+        )
+    }
+
+    @Composable
+    private fun Item(
+        color: Color,
+        size: Dp = itemSizeDp,
+        crossAxisSize: Dp = crossAxisSizeDp,
+        animSpec: FiniteAnimationSpec<Float>? = AnimSpec
+    ) {
+        Box(
+            Modifier
+                .animateItem(appearanceSpec = animSpec, placementSpec = null)
+                .background(color)
+                .requiredHeight(size)
+                .requiredWidth(crossAxisSize)
+        )
+    }
+}
+
+private val FrameDuration = 16L
+private val Duration = 64L // 4 frames, so we get 0f, 0.25f, 0.5f, 0.75f and 1f fractions
+private val AnimSpec = tween<Float>(Duration.toInt(), easing = LinearEasing)
+private val ContainerTag = "container"
+
+private fun Float.isCloseTo(expected: Float) = abs(this - expected) < 0.01f
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListAnimateItemPlacementTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListItemPlacementAnimationTest.kt
similarity index 100%
rename from compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListAnimateItemPlacementTest.kt
rename to compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListItemPlacementAnimationTest.kt
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyItemScopeImpl.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyItemScopeImpl.kt
index 48c3e74..33940b8 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyItemScopeImpl.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyItemScopeImpl.kt
@@ -17,6 +17,10 @@
 package androidx.compose.foundation.lazy
 
 import androidx.compose.animation.core.FiniteAnimationSpec
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.VisibilityThreshold
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
 import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.lazy.layout.LazyLayoutAnimateItemModifierNode
 import androidx.compose.runtime.State
@@ -71,8 +75,30 @@
     )
 
     @ExperimentalFoundationApi
-    override fun Modifier.animateItemPlacement(animationSpec: FiniteAnimationSpec<IntOffset>) =
-        this then AnimateItemPlacementElement(animationSpec)
+    override fun Modifier.animateItemPlacement(
+        animationSpec: FiniteAnimationSpec<IntOffset>
+    ): Modifier = animateItem(
+        appearanceSpec = null,
+        placementSpec = animationSpec
+    )
+}
+
+@ExperimentalFoundationApi
+internal fun Modifier.animateItem(
+    appearanceSpec: FiniteAnimationSpec<Float>? = tween(220),
+    placementSpec: FiniteAnimationSpec<IntOffset>? = spring(
+        stiffness = Spring.StiffnessMediumLow,
+        visibilityThreshold = IntOffset.VisibilityThreshold
+    )
+): Modifier {
+    return if (appearanceSpec == null && placementSpec == null) {
+        this
+    } else {
+        this then AnimateItemElement(
+            appearanceSpec,
+            placementSpec
+        )
+    }
 }
 
 private class ParentSizeElement(
@@ -154,37 +180,37 @@
     }
 }
 
-private class AnimateItemPlacementElement(
-    val animationSpec: FiniteAnimationSpec<IntOffset>
+private data class AnimateItemElement(
+    val appearanceSpec: FiniteAnimationSpec<Float>?,
+    val placementSpec: FiniteAnimationSpec<IntOffset>?,
 ) : ModifierNodeElement<AnimateItemPlacementNode>() {
 
-    override fun create(): AnimateItemPlacementNode = AnimateItemPlacementNode(animationSpec)
+    override fun create(): AnimateItemPlacementNode =
+        AnimateItemPlacementNode(appearanceSpec, placementSpec)
 
     override fun update(node: AnimateItemPlacementNode) {
-        node.delegatingNode.placementAnimationSpec = animationSpec
-    }
-
-    override fun equals(other: Any?): Boolean {
-        if (this === other) return true
-        if (other !is AnimateItemPlacementElement) return false
-        return animationSpec != other.animationSpec
-    }
-
-    override fun hashCode(): Int {
-        return animationSpec.hashCode()
+        node.delegatingNode.appearanceSpec = appearanceSpec
+        node.delegatingNode.placementSpec = placementSpec
     }
 
     override fun InspectorInfo.inspectableProperties() {
+        // TODO update the name here once we expose a new public api
         name = "animateItemPlacement"
-        value = animationSpec
+        value = placementSpec
     }
 }
 
 private class AnimateItemPlacementNode(
-    animationSpec: FiniteAnimationSpec<IntOffset>
+    appearanceSpec: FiniteAnimationSpec<Float>?,
+    placementSpec: FiniteAnimationSpec<IntOffset>?,
 ) : DelegatingNode(), ParentDataModifierNode {
 
-    val delegatingNode = delegate(LazyLayoutAnimateItemModifierNode(animationSpec))
+    val delegatingNode = delegate(
+        LazyLayoutAnimateItemModifierNode(
+            appearanceSpec,
+            placementSpec
+        )
+    )
 
     override fun Density.modifyParentData(parentData: Any?): Any = delegatingNode
 }
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyList.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyList.kt
index 99f6a33..d9f7585 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyList.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyList.kt
@@ -335,7 +335,7 @@
             horizontalArrangement = horizontalArrangement,
             reverseLayout = reverseLayout,
             density = this,
-            placementAnimator = state.placementAnimator,
+            itemAnimator = state.itemAnimator,
             beyondBoundsItemCount = beyondBoundsItemCount,
             pinnedItems = pinnedItems,
             hasLookaheadPassOccurred = hasLookaheadPassOccurred,
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListItemPlacementAnimator.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListItemAnimator.kt
similarity index 85%
rename from compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListItemPlacementAnimator.kt
rename to compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListItemAnimator.kt
index 7ad2893..023c327 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListItemPlacementAnimator.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListItemAnimator.kt
@@ -23,17 +23,19 @@
 import androidx.compose.ui.util.fastForEach
 
 /**
- * Handles the item placement animations when it is set via [LazyItemScope.animateItemPlacement].
+ * Handles the item animations when it is set via [LazyItemScope.animateItemPlacement].
  *
- * This class is responsible for detecting when item position changed, figuring our start/end
- * offsets and starting the animations.
+ * This class is responsible for:
+ * - animating item appearance for the new items.
+ * - detecting when item position changed, figuring our start/end offsets and starting the
+ * animations for placement animations. *
  */
-internal class LazyListItemPlacementAnimator {
+internal class LazyListItemAnimator {
     // contains the keys of the active items with animation node.
     private val activeKeys = mutableSetOf<Any>()
 
     // snapshot of the key to index map used for the last measuring.
-    private var keyIndexMap: LazyLayoutKeyIndexMap = LazyLayoutKeyIndexMap.Empty
+    private var keyIndexMap: LazyLayoutKeyIndexMap? = null
 
     // keeps the index of the first visible item index.
     private var firstVisibleIndex = 0
@@ -60,7 +62,12 @@
         isLookingAhead: Boolean,
         hasLookaheadOccurred: Boolean
     ) {
-        if (!positionedItems.fastAny { it.hasAnimations } && activeKeys.isEmpty()) {
+        val previousKeyToIndexMap = this.keyIndexMap
+        val keyIndexMap = itemProvider.keyIndexMap
+        this.keyIndexMap = keyIndexMap
+
+        val hasAnimations = positionedItems.fastAny { it.hasAnimations }
+        if (!hasAnimations && activeKeys.isEmpty()) {
             // no animations specified - no work needed
             reset()
             return
@@ -69,9 +76,6 @@
         val previousFirstVisibleIndex = firstVisibleIndex
         firstVisibleIndex = positionedItems.firstOrNull()?.index ?: 0
 
-        val previousKeyToIndexMap = keyIndexMap
-        keyIndexMap = itemProvider.keyIndexMap
-
         val mainAxisLayoutSize = if (isVertical) layoutHeight else layoutWidth
 
         // the consumed scroll is considered as a delta we don't need to animate
@@ -93,8 +97,8 @@
             if (item.hasAnimations) {
                 if (!activeKeys.contains(item.key)) {
                     activeKeys += item.key
-                    val previousIndex = previousKeyToIndexMap.getIndex(item.key)
-                    if (previousIndex != -1 && item.index != previousIndex) {
+                    val previousIndex = previousKeyToIndexMap?.getIndex(item.key) ?: -1
+                    if (item.index != previousIndex && previousIndex != -1) {
                         if (previousIndex < previousFirstVisibleIndex) {
                             // the larger index will be in the start of the list
                             movingInFromStartBound.add(item)
@@ -106,6 +110,11 @@
                             item,
                             item.getOffset(0).let { if (item.isVertical) it.y else it.x }
                         )
+                        if (previousIndex == -1 && previousKeyToIndexMap != null) {
+                            item.forEachNode { _, node ->
+                                node.animateAppearance()
+                            }
+                        }
                     }
                 } else {
                     if (shouldSetupAnimation) {
@@ -115,7 +124,7 @@
                                 node.rawOffset += scrollOffset
                             }
                         }
-                        startAnimationsIfNeeded(item)
+                        startPlacementAnimationsIfNeeded(item)
                     }
                 }
             } else {
@@ -125,13 +134,13 @@
         }
 
         var accumulatedOffset = 0
-        if (shouldSetupAnimation) {
+        if (shouldSetupAnimation && previousKeyToIndexMap != null) {
             movingInFromStartBound.sortByDescending { previousKeyToIndexMap.getIndex(it.key) }
             movingInFromStartBound.fastForEach { item ->
                 accumulatedOffset += item.size
                 val mainAxisOffset = 0 - accumulatedOffset
                 initializeNode(item, mainAxisOffset)
-                startAnimationsIfNeeded(item)
+                startPlacementAnimationsIfNeeded(item)
             }
             accumulatedOffset = 0
             movingInFromEndBound.sortBy { previousKeyToIndexMap.getIndex(it.key) }
@@ -139,7 +148,7 @@
                 val mainAxisOffset = mainAxisLayoutSize + accumulatedOffset
                 accumulatedOffset += item.size
                 initializeNode(item, mainAxisOffset)
-                startAnimationsIfNeeded(item)
+                startPlacementAnimationsIfNeeded(item)
             }
         }
 
@@ -155,12 +164,12 @@
                 // check if we have any active placement animation on the item
                 var inProgress = false
                 repeat(item.placeablesCount) {
-                    if (item.getParentData(it).node?.isAnimationInProgress == true) {
+                    if (item.getParentData(it).node?.isPlacementAnimationInProgress == true) {
                         inProgress = true
                         return@repeat
                     }
                 }
-                if ((!inProgress && newIndex == previousKeyToIndexMap.getIndex(key))) {
+                if ((!inProgress && newIndex == previousKeyToIndexMap?.getIndex(key))) {
                     activeKeys.remove(key)
                 } else {
                     if (newIndex < firstVisibleIndex) {
@@ -180,7 +189,7 @@
 
             item.position(mainAxisOffset, layoutWidth, layoutHeight)
             if (shouldSetupAnimation) {
-                startAnimationsIfNeeded(item)
+                startPlacementAnimationsIfNeeded(item)
             }
         }
 
@@ -192,7 +201,7 @@
 
             item.position(mainAxisOffset, layoutWidth, layoutHeight)
             if (shouldSetupAnimation) {
-                startAnimationsIfNeeded(item)
+                startPlacementAnimationsIfNeeded(item)
             }
         }
 
@@ -238,7 +247,7 @@
         }
     }
 
-    private fun startAnimationsIfNeeded(item: LazyListMeasuredItem) {
+    private fun startPlacementAnimationsIfNeeded(item: LazyListMeasuredItem) {
         item.forEachNode { placeableIndex, node ->
             val newTarget = item.getOffset(placeableIndex)
             val currentTarget = node.rawOffset
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasure.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasure.kt
index 3a7dce6..3876e05 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasure.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasure.kt
@@ -56,7 +56,7 @@
     horizontalArrangement: Arrangement.Horizontal?,
     reverseLayout: Boolean,
     density: Density,
-    placementAnimator: LazyListItemPlacementAnimator,
+    itemAnimator: LazyListItemAnimator,
     beyondBoundsItemCount: Int,
     pinnedItems: List<Int>,
     hasLookaheadPassOccurred: Boolean,
@@ -69,12 +69,24 @@
     require(afterContentPadding >= 0) { "invalid afterContentPadding" }
     if (itemsCount <= 0) {
         // empty data set. reset the current scroll and report zero size
+        val layoutWidth = constraints.minWidth
+        val layoutHeight = constraints.minHeight
+        itemAnimator.onMeasured(
+            consumedScroll = 0,
+            layoutWidth = layoutWidth,
+            layoutHeight = layoutHeight,
+            positionedItems = mutableListOf(),
+            itemProvider = measuredItemProvider,
+            isVertical = isVertical,
+            isLookingAhead = isLookingAhead,
+            hasLookaheadOccurred = hasLookaheadPassOccurred
+        )
         return LazyListMeasureResult(
             firstVisibleItem = null,
             firstVisibleItemScrollOffset = 0,
             canScrollForward = false,
             consumedScroll = 0f,
-            measureResult = layout(constraints.minWidth, constraints.minHeight) {},
+            measureResult = layout(layoutWidth, layoutHeight) {},
             scrollBackAmount = 0f,
             visibleItemsInfo = emptyList(),
             viewportStartOffset = -beforeContentPadding,
@@ -301,7 +313,7 @@
             density = density,
         )
 
-        placementAnimator.onMeasured(
+        itemAnimator.onMeasured(
             consumedScroll = consumedScroll.toInt(),
             layoutWidth = layoutWidth,
             layoutHeight = layoutHeight,
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasuredItem.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasuredItem.kt
index f13fa49..3420577 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasuredItem.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasuredItem.kt
@@ -20,6 +20,7 @@
 import androidx.compose.foundation.lazy.layout.LazyLayoutAnimateItemModifierNode
 import androidx.compose.foundation.lazy.layout.LazyLayoutAnimateItemModifierNode.Companion.NotInitialized
 import androidx.compose.ui.Alignment
+import androidx.compose.ui.graphics.GraphicsLayerScope
 import androidx.compose.ui.layout.Placeable
 import androidx.compose.ui.unit.IntOffset
 import androidx.compose.ui.unit.LayoutDirection
@@ -144,6 +145,7 @@
             val maxOffset = maxMainAxisOffset
             var offset = getOffset(index)
             val animateNode = getParentData(index) as? LazyLayoutAnimateItemModifierNode
+            val layerBlock: GraphicsLayerScope.() -> Unit
             if (animateNode != null) {
                 if (isLookingAhead) {
                     // Skip animation in lookahead pass
@@ -161,10 +163,13 @@
                         (targetOffset.mainAxis >= maxOffset &&
                             animatedOffset.mainAxis >= maxOffset)
                     ) {
-                        animateNode.cancelAnimation()
+                        animateNode.cancelPlacementAnimation()
                     }
                     offset = animatedOffset
                 }
+                layerBlock = animateNode
+            } else {
+                layerBlock = DefaultLayerBlock
             }
             if (reverseLayout) {
                 offset = offset.copy { mainAxisOffset ->
@@ -173,9 +178,9 @@
             }
             offset += visualOffset
             if (isVertical) {
-                placeable.placeWithLayer(offset)
+                placeable.placeWithLayer(offset, layerBlock = layerBlock)
             } else {
-                placeable.placeRelativeWithLayer(offset)
+                placeable.placeRelativeWithLayer(offset, layerBlock = layerBlock)
             }
         }
     }
@@ -187,3 +192,8 @@
 }
 
 private const val Unset = Int.MIN_VALUE
+
+/**
+ * Block on [GraphicsLayerScope] which applies the default layer parameters.
+ */
+private val DefaultLayerBlock: GraphicsLayerScope.() -> Unit = {}
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListState.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListState.kt
index e4b170a..f382480 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListState.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListState.kt
@@ -229,7 +229,7 @@
      */
     internal val awaitLayoutModifier = AwaitFirstLayoutModifier()
 
-    internal val placementAnimator = LazyListItemPlacementAnimator()
+    internal val itemAnimator = LazyListItemAnimator()
 
     internal val beyondBoundsInfo = LazyLayoutBeyondBoundsInfo()
 
@@ -267,7 +267,7 @@
     internal fun snapToItemIndexInternal(index: Int, scrollOffset: Int) {
         scrollPosition.requestPosition(index, scrollOffset)
         // placement animation is not needed because we snap into a new position.
-        placementAnimator.reset()
+        itemAnimator.reset()
         remeasurement?.forceRemeasure()
     }
 
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridItemPlacementAnimator.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridItemPlacementAnimator.kt
index 918f2a2..58be76c 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridItemPlacementAnimator.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridItemPlacementAnimator.kt
@@ -177,7 +177,7 @@
                 // check if we have any active placement animation on the item
                 var inProgress = false
                 repeat(item.placeablesCount) {
-                    if (item.getParentData(it).node?.isAnimationInProgress == true) {
+                    if (item.getParentData(it).node?.isPlacementAnimationInProgress == true) {
                         inProgress = true
                         return@repeat
                     }
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridItemScopeImpl.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridItemScopeImpl.kt
index 09fb209..ea43720 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridItemScopeImpl.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridItemScopeImpl.kt
@@ -41,7 +41,7 @@
     override fun create(): AnimateItemPlacementNode = AnimateItemPlacementNode(animationSpec)
 
     override fun update(node: AnimateItemPlacementNode) {
-        node.delegatingNode.placementAnimationSpec = animationSpec
+        node.delegatingNode.placementSpec = animationSpec
     }
 
     override fun equals(other: Any?): Boolean {
@@ -64,7 +64,7 @@
     animationSpec: FiniteAnimationSpec<IntOffset>
 ) : DelegatingNode(), ParentDataModifierNode {
 
-    val delegatingNode = delegate(LazyLayoutAnimateItemModifierNode(animationSpec))
+    val delegatingNode = delegate(LazyLayoutAnimateItemModifierNode(null, animationSpec))
 
     override fun Density.modifyParentData(parentData: Any?): Any = delegatingNode
 }
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredItem.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredItem.kt
index 9798f78..3b698b1 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredItem.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/grid/LazyGridMeasuredItem.kt
@@ -140,7 +140,7 @@
                 if ((offset.mainAxis <= minOffset && animatedOffset.mainAxis <= minOffset) ||
                     (offset.mainAxis >= maxOffset && animatedOffset.mainAxis >= maxOffset)
                 ) {
-                    animateNode.cancelAnimation()
+                    animateNode.cancelPlacementAnimation()
                 }
                 offset = animatedOffset
             }
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutAnimateItemModifierNode.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutAnimateItemModifierNode.kt
index 8a81ca6..aeb8a58 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutAnimateItemModifierNode.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutAnimateItemModifierNode.kt
@@ -24,22 +24,31 @@
 import androidx.compose.animation.core.VisibilityThreshold
 import androidx.compose.animation.core.spring
 import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.GraphicsLayerScope
 import androidx.compose.ui.unit.IntOffset
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.launch
 
 internal class LazyLayoutAnimateItemModifierNode(
-    var placementAnimationSpec: FiniteAnimationSpec<IntOffset>
-) : Modifier.Node() {
+    var appearanceSpec: FiniteAnimationSpec<Float>?,
+    var placementSpec: FiniteAnimationSpec<IntOffset>?
+) : Modifier.Node(), (GraphicsLayerScope) -> Unit {
 
     /**
      * Returns true when the placement animation is currently in progress so the parent
      * should continue composing this item.
      */
-    var isAnimationInProgress by mutableStateOf(false)
+    var isPlacementAnimationInProgress by mutableStateOf(false)
+        private set
+
+    /**
+     * Returns true when the appearance animation is currently in progress.
+     */
+    var isAppearanceAnimationInProgress by mutableStateOf(false)
         private set
 
     /**
@@ -53,6 +62,8 @@
 
     private val placementDeltaAnimation = Animatable(IntOffset.Zero, IntOffset.VectorConverter)
 
+    private val visibilityAnimation = Animatable(1f, Float.VectorConverter)
+
     /**
      * Current delta to apply for a placement offset. Updates every animation frame.
      * The settled value is [IntOffset.Zero] so the animation is always targeting this value.
@@ -60,15 +71,18 @@
     var placementDelta by mutableStateOf(IntOffset.Zero)
         private set
 
+    var visibility by mutableFloatStateOf(1f)
+        private set
+
     /**
-     * Cancels the ongoing animation if there is one.
+     * Cancels the ongoing placement animation if there is one.
      */
-    fun cancelAnimation() {
-        if (isAnimationInProgress) {
+    fun cancelPlacementAnimation() {
+        if (isPlacementAnimationInProgress) {
             coroutineScope.launch {
                 placementDeltaAnimation.snapTo(IntOffset.Zero)
                 placementDelta = IntOffset.Zero
-                isAnimationInProgress = false
+                isPlacementAnimationInProgress = false
             }
         }
     }
@@ -83,20 +97,21 @@
      * Animate the placement by the given [delta] offset.
      */
     fun animatePlacementDelta(delta: IntOffset) {
+        val spec = placementSpec ?: return
         val totalDelta = placementDelta - delta
         placementDelta = totalDelta
-        isAnimationInProgress = true
+        isPlacementAnimationInProgress = true
         coroutineScope.launch {
             try {
-                val spec = if (placementDeltaAnimation.isRunning) {
+                val finalSpec = if (placementDeltaAnimation.isRunning) {
                     // when interrupted, use the default spring, unless the spec is a spring.
-                    if (placementAnimationSpec is SpringSpec<IntOffset>) {
-                        placementAnimationSpec
+                    if (spec is SpringSpec<IntOffset>) {
+                        spec
                     } else {
                         InterruptionSpec
                     }
                 } else {
-                    placementAnimationSpec
+                    spec
                 }
                 if (!placementDeltaAnimation.isRunning) {
                     // if not running we can snap to the initial value and animate to zero
@@ -106,12 +121,12 @@
                 // we have to continue the animation from the current value, but keep the needed
                 // total delta for the new animation.
                 val animationTarget = placementDeltaAnimation.value - totalDelta
-                placementDeltaAnimation.animateTo(animationTarget, spec) {
+                placementDeltaAnimation.animateTo(animationTarget, finalSpec) {
                     // placementDelta is calculated as if we always animate to target equal to zero
                     placementDelta = value - animationTarget
                 }
 
-                isAnimationInProgress = false
+                isPlacementAnimationInProgress = false
             } catch (_: CancellationException) {
                 // we don't reset inProgress in case of cancellation as it means
                 // there is a new animation started which would reset it later
@@ -119,11 +134,36 @@
         }
     }
 
+    fun animateAppearance() {
+        val spec = appearanceSpec
+        if (isAppearanceAnimationInProgress || spec == null) {
+            return
+        }
+        isAppearanceAnimationInProgress = true
+        visibility = 0f
+        coroutineScope.launch {
+            try {
+                visibilityAnimation.snapTo(0f)
+                visibilityAnimation.animateTo(1f, spec) {
+                    visibility = value
+                }
+            } finally {
+                isAppearanceAnimationInProgress = false
+            }
+        }
+    }
+
     override fun onDetach() {
         placementDelta = IntOffset.Zero
-        isAnimationInProgress = false
+        isPlacementAnimationInProgress = false
         rawOffset = NotInitialized
-        // placementDeltaAnimation will be canceled because coroutineScope will be canceled.
+        visibility = 1f
+        isAppearanceAnimationInProgress = false
+        // animations will be canceled because coroutineScope will be canceled.
+    }
+
+    override fun invoke(scope: GraphicsLayerScope) {
+        scope.alpha = visibility
     }
 
     companion object {
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridItemPlacementAnimator.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridItemPlacementAnimator.kt
index 4c36812..b46ca41 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridItemPlacementAnimator.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridItemPlacementAnimator.kt
@@ -161,7 +161,7 @@
                 // check if we have any active placement animation on the item
                 var inProgress = false
                 repeat(item.placeablesCount) {
-                    if (item.getParentData(it).node?.isAnimationInProgress == true) {
+                    if (item.getParentData(it).node?.isPlacementAnimationInProgress == true) {
                         inProgress = true
                         return@repeat
                     }
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridItemScope.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridItemScope.kt
index 5982746..1ff041d 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridItemScope.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridItemScope.kt
@@ -72,7 +72,7 @@
     override fun create(): AnimateItemPlacementNode = AnimateItemPlacementNode(animationSpec)
 
     override fun update(node: AnimateItemPlacementNode) {
-        node.delegatingNode.placementAnimationSpec = animationSpec
+        node.delegatingNode.placementSpec = animationSpec
     }
 
     override fun equals(other: Any?): Boolean {
@@ -95,7 +95,7 @@
     animationSpec: FiniteAnimationSpec<IntOffset>
 ) : DelegatingNode(), ParentDataModifierNode {
 
-    val delegatingNode = delegate(LazyLayoutAnimateItemModifierNode(animationSpec))
+    val delegatingNode = delegate(LazyLayoutAnimateItemModifierNode(null, animationSpec))
 
     override fun Density.modifyParentData(parentData: Any?): Any = delegatingNode
 }
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasure.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasure.kt
index 0ff47af..d1b13f3 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasure.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasure.kt
@@ -1127,7 +1127,7 @@
                     if ((offset.mainAxis <= minOffset && animatedOffset.mainAxis <= minOffset) ||
                         (offset.mainAxis >= maxOffset && animatedOffset.mainAxis >= maxOffset)
                     ) {
-                        animateNode.cancelAnimation()
+                        animateNode.cancelPlacementAnimation()
                     }
                     offset = animatedOffset
                 }