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
}