All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.androidx.compose.foundation.lazy.layout.LazyLayoutItemAnimator.kt Maven / Gradle / Ivy

Go to download

Higher level abstractions of the Compose UI primitives. This library is design system agnostic, providing the high-level building blocks for both application and design-system developers

The newest version!
/*
 * Copyright 2024 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.layout

import androidx.collection.mutableScatterMapOf
import androidx.collection.mutableScatterSetOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.GraphicsContext
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.node.DrawModifierNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.invalidateDraw
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastForEach
import kotlinx.coroutines.CoroutineScope

/**
 * Handles the item animations when it is set via "animateItem" modifiers.
 *
 * 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.
 * - animating item disappearance for the removed items.
 */
internal class LazyLayoutItemAnimator {
    // state containing relevant info for active items.
    private val keyToItemInfoMap = mutableScatterMapOf()

    // snapshot of the key to index map used for the last measuring.
    private var keyIndexMap: LazyLayoutKeyIndexMap? = null

    // keeps the index of the first visible item index.
    private var firstVisibleIndex = 0

    // stored to not allocate it every pass.
    private val movingAwayKeys = mutableScatterSetOf()
    private val movingInFromStartBound = mutableListOf()
    private val movingInFromEndBound = mutableListOf()
    private val movingAwayToStartBound = mutableListOf()
    private val movingAwayToEndBound = mutableListOf()
    private val disappearingItems = mutableListOf()
    private var displayingNode: DrawModifierNode? = null

    /**
     * Should be called after the measuring so we can detect position changes and start animations.
     *
     * Note that this method can compose new item and add it into the [positionedItems] list.
     */
    fun onMeasured(
        consumedScroll: Int,
        layoutWidth: Int,
        layoutHeight: Int,
        positionedItems: MutableList,
        keyIndexMap: LazyLayoutKeyIndexMap,
        itemProvider: LazyLayoutMeasuredItemProvider,
        isVertical: Boolean,
        isLookingAhead: Boolean,
        laneCount: Int,
        hasLookaheadOccurred: Boolean,
        layoutMinOffset: Int,
        layoutMaxOffset: Int,
        coroutineScope: CoroutineScope,
        graphicsContext: GraphicsContext
    ) {
        val previousKeyToIndexMap = this.keyIndexMap
        this.keyIndexMap = keyIndexMap

        val hasAnimations = positionedItems.fastAny { it.hasAnimations }
        if (!hasAnimations && keyToItemInfoMap.isEmpty()) {
            // no animations specified - no work needed - clear animation info
            releaseAnimations()
            return
        }

        val previousFirstVisibleIndex = firstVisibleIndex
        firstVisibleIndex = positionedItems.firstOrNull()?.index ?: 0

        // the consumed scroll is considered as a delta we don't need to animate
        val scrollOffset =
            if (isVertical) {
                IntOffset(0, consumedScroll)
            } else {
                IntOffset(consumedScroll, 0)
            }

        // Only setup animations when we have access to target value in the current pass, which
        // means lookahead pass, or regular pass when not in a lookahead scope.
        val shouldSetupAnimation = isLookingAhead || !hasLookaheadOccurred
        // first add all items we had in the previous run
        keyToItemInfoMap.forEachKey { movingAwayKeys.add(it) }
        // iterate through the items which are visible (without animated offsets)
        positionedItems.fastForEach { item ->
            // remove items we have in the current one as they are still visible.
            movingAwayKeys.remove(item.key)
            if (item.hasAnimations) {
                val itemInfo = keyToItemInfoMap[item.key]
                val previousIndex = previousKeyToIndexMap?.getIndex(item.key) ?: -1
                val shouldAnimateAppearance = previousIndex == -1 && previousKeyToIndexMap != null
                // there is no state associated with this item yet
                if (itemInfo == null) {
                    val newItemInfo = ItemInfo()
                    newItemInfo.updateAnimation(
                        item,
                        coroutineScope,
                        graphicsContext,
                        layoutMinOffset,
                        layoutMaxOffset,
                        item.crossAxisOffset,
                    )
                    keyToItemInfoMap[item.key] = newItemInfo
                    if (item.index != previousIndex && previousIndex != -1) {
                        if (previousIndex < previousFirstVisibleIndex) {
                            // the larger index will be in the start of the list
                            movingInFromStartBound.add(item)
                        } else {
                            movingInFromEndBound.add(item)
                        }
                    } else {
                        initializeAnimation(
                            item,
                            item.getOffset(0).let { if (item.isVertical) it.y else it.x },
                            newItemInfo
                        )
                        if (shouldAnimateAppearance) {
                            newItemInfo.animations.forEach { it?.animateAppearance() }
                        }
                    }
                } else {
                    if (shouldSetupAnimation) {
                        itemInfo.updateAnimation(
                            item,
                            coroutineScope,
                            graphicsContext,
                            layoutMinOffset,
                            layoutMaxOffset,
                            item.crossAxisOffset,
                        )
                        itemInfo.animations.forEach { animation ->
                            if (
                                animation != null &&
                                    animation.rawOffset != LazyLayoutItemAnimation.NotInitialized
                            ) {
                                animation.rawOffset += scrollOffset
                            }
                        }
                        if (shouldAnimateAppearance) {
                            itemInfo.animations.forEach {
                                if (it != null) {
                                    if (it.isDisappearanceAnimationInProgress) {
                                        disappearingItems.remove(it)
                                        displayingNode?.invalidateDraw()
                                    }
                                    it.animateAppearance()
                                }
                            }
                        }
                        startPlacementAnimationsIfNeeded(item)
                    }
                }
            } else {
                // no animation, clean up if needed
                removeInfoForKey(item.key)
            }
        }

        val accumulatedOffsetPerLane = IntArray(laneCount)
        if (shouldSetupAnimation && previousKeyToIndexMap != null) {
            if (movingInFromStartBound.isNotEmpty()) {
                movingInFromStartBound.sortByDescending { previousKeyToIndexMap.getIndex(it.key) }
                movingInFromStartBound.fastForEach { item ->
                    val accumulatedOffset = accumulatedOffsetPerLane.updateAndReturnOffsetFor(item)
                    val mainAxisOffset = layoutMinOffset - accumulatedOffset
                    initializeAnimation(item, mainAxisOffset)
                    startPlacementAnimationsIfNeeded(item)
                }
                accumulatedOffsetPerLane.fill(0)
            }
            if (movingInFromEndBound.isNotEmpty()) {
                movingInFromEndBound.sortBy { previousKeyToIndexMap.getIndex(it.key) }
                movingInFromEndBound.fastForEach { item ->
                    val accumulatedOffset = accumulatedOffsetPerLane.updateAndReturnOffsetFor(item)
                    val mainAxisOffset =
                        layoutMaxOffset + accumulatedOffset - item.mainAxisSizeWithSpacings
                    initializeAnimation(item, mainAxisOffset)
                    startPlacementAnimationsIfNeeded(item)
                }
                accumulatedOffsetPerLane.fill(0)
            }
        }

        movingAwayKeys.forEach { key ->
            // found an item which was in our map previously but is not a part of the
            // positionedItems now
            // TODO(jossiwolf): In some cases, keyToItemInfoMap and movingAwayKeys can get out of
            //  sync. If that's the case, we can not play an animation in any case as the item is
            //  already gone (b/352482051). Follow-up: b/354695943
            val info = keyToItemInfoMap[key] ?: return@forEach
            val newIndex = keyIndexMap.getIndex(key)

            // it is possible that we are being remeasured with smaller laneCount. make sure
            // `lane` and `span` we remembered are not larger than the new max values.
            info.span = minOf(laneCount, info.span)
            info.lane = minOf(laneCount - info.span, info.lane)

            if (newIndex == -1) {
                var isProgress = false
                info.animations.forEachIndexed { index, animation ->
                    if (animation != null) {
                        if (animation.isDisappearanceAnimationInProgress) {
                            isProgress = true
                        } else if (animation.isDisappearanceAnimationFinished) {
                            animation.release()
                            info.animations[index] = null
                            disappearingItems.remove(animation)
                            displayingNode?.invalidateDraw()
                        } else {
                            if (animation.layer != null) {
                                animation.animateDisappearance()
                            }
                            if (animation.isDisappearanceAnimationInProgress) {
                                disappearingItems.add(animation)
                                displayingNode?.invalidateDraw()
                                isProgress = true
                            } else {
                                animation.release()
                                info.animations[index] = null
                            }
                        }
                    }
                }
                if (!isProgress) {
                    removeInfoForKey(key)
                }
            } else {
                val item =
                    itemProvider.getAndMeasure(
                        index = newIndex,
                        constraints = info.constraints!!,
                        lane = info.lane,
                        span = info.span
                    )

                item.nonScrollableItem = true
                // check if we have any active placement animation on the item
                val inProgress = info.animations.any { it?.isPlacementAnimationInProgress == true }
                if ((!inProgress && newIndex == previousKeyToIndexMap?.getIndex(key))) {
                    removeInfoForKey(key)
                } else {
                    // anytime we compose a new item, and we use it,
                    // we need to update our item info mapping
                    info.updateAnimation(
                        item,
                        coroutineScope,
                        graphicsContext,
                        layoutMinOffset,
                        layoutMaxOffset,
                        crossAxisOffset = info.crossAxisOffset
                    )
                    if (newIndex < firstVisibleIndex) {
                        movingAwayToStartBound.add(item)
                    } else {
                        movingAwayToEndBound.add(item)
                    }
                }
            }
        }

        if (movingAwayToStartBound.isNotEmpty()) {
            movingAwayToStartBound.sortByDescending { keyIndexMap.getIndex(it.key) }
            movingAwayToStartBound.fastForEach { item ->
                val itemInfo = keyToItemInfoMap[item.key]!!
                val accumulatedOffset = accumulatedOffsetPerLane.updateAndReturnOffsetFor(item)
                val mainAxisOffset =
                    if (isLookingAhead) {
                        positionedItems.first().mainAxisOffset
                    } else {
                        itemInfo.layoutMinOffset
                    } - accumulatedOffset

                item.position(
                    mainAxisOffset = mainAxisOffset,
                    crossAxisOffset = itemInfo.crossAxisOffset,
                    layoutWidth = layoutWidth,
                    layoutHeight = layoutHeight
                )
                if (shouldSetupAnimation) {
                    startPlacementAnimationsIfNeeded(item, isMovingAway = true)
                }
            }
            accumulatedOffsetPerLane.fill(0)
        }

        if (movingAwayToEndBound.isNotEmpty()) {
            movingAwayToEndBound.sortBy { keyIndexMap.getIndex(it.key) }
            movingAwayToEndBound.fastForEach { item ->
                val itemInfo = keyToItemInfoMap[item.key]!!
                val accumulatedOffset = accumulatedOffsetPerLane.updateAndReturnOffsetFor(item)
                val mainAxisOffset =
                    if (isLookingAhead) positionedItems.last().mainAxisOffset
                    else {
                        itemInfo.layoutMaxOffset - item.mainAxisSizeWithSpacings
                    } + accumulatedOffset

                item.position(
                    mainAxisOffset = mainAxisOffset,
                    crossAxisOffset = itemInfo.crossAxisOffset,
                    layoutWidth = layoutWidth,
                    layoutHeight = layoutHeight,
                )

                if (shouldSetupAnimation) {
                    startPlacementAnimationsIfNeeded(item, isMovingAway = true)
                }
            }
        }

        // This adds the new items to the list of positioned items while keeping the index of
        // the positioned items sorted in ascending order.
        positionedItems.addAll(0, movingAwayToStartBound.apply { reverse() })
        positionedItems.addAll(movingAwayToEndBound)

        movingInFromStartBound.clear()
        movingInFromEndBound.clear()
        movingAwayToStartBound.clear()
        movingAwayToEndBound.clear()
        movingAwayKeys.clear()
    }

    private fun removeInfoForKey(key: Any) {
        keyToItemInfoMap.remove(key)?.animations?.forEach { it?.release() }
    }

    /**
     * Should be called when the animations are not needed for the next positions change, for
     * example when we snap to a new position.
     */
    fun reset() {
        releaseAnimations()
        keyIndexMap = null
        firstVisibleIndex = -1
    }

    private fun releaseAnimations() {
        if (keyToItemInfoMap.isNotEmpty()) {
            keyToItemInfoMap.forEachValue {
                it.animations.forEach { animation -> animation?.release() }
            }
            keyToItemInfoMap.clear()
        }
    }

    private fun initializeAnimation(
        item: T,
        mainAxisOffset: Int,
        itemInfo: ItemInfo = keyToItemInfoMap[item.key]!!
    ) {
        val firstPlaceableOffset = item.getOffset(0)

        val targetFirstPlaceableOffset =
            if (item.isVertical) {
                firstPlaceableOffset.copy(y = mainAxisOffset)
            } else {
                firstPlaceableOffset.copy(x = mainAxisOffset)
            }

        // initialize offsets
        itemInfo.animations.forEachIndexed { placeableIndex, animation ->
            if (animation != null) {
                val diffToFirstPlaceableOffset =
                    item.getOffset(placeableIndex) - firstPlaceableOffset
                animation.rawOffset = targetFirstPlaceableOffset + diffToFirstPlaceableOffset
            }
        }
    }

    private fun startPlacementAnimationsIfNeeded(item: T, isMovingAway: Boolean = false) {
        val itemInfo = keyToItemInfoMap[item.key]!!
        itemInfo.animations.forEachIndexed { placeableIndex, animation ->
            if (animation != null) {
                val newTarget = item.getOffset(placeableIndex)
                val currentTarget = animation.rawOffset
                if (
                    currentTarget != LazyLayoutItemAnimation.NotInitialized &&
                        currentTarget != newTarget
                ) {
                    animation.animatePlacementDelta(newTarget - currentTarget, isMovingAway)
                }
                animation.rawOffset = newTarget
            }
        }
    }

    fun getAnimation(key: Any, placeableIndex: Int): LazyLayoutItemAnimation? =
        keyToItemInfoMap[key]?.animations?.get(placeableIndex)

    private fun IntArray.updateAndReturnOffsetFor(item: T): Int {
        val lane = item.lane
        val span = item.span
        var maxOffset = 0
        for (i in lane until lane + span) {
            this[i] += item.mainAxisSizeWithSpacings
            maxOffset = maxOf(maxOffset, this[i])
        }
        return maxOffset
    }

    val minSizeToFitDisappearingItems: IntSize
        get() {
            var size = IntSize.Zero
            disappearingItems.fastForEach {
                val layer = it.layer
                if (layer != null) {
                    size =
                        IntSize(
                            width = maxOf(size.width, it.rawOffset.x + layer.size.width),
                            height = maxOf(size.height, it.rawOffset.y + layer.size.height)
                        )
                }
            }
            return size
        }

    val modifier: Modifier = DisplayingDisappearingItemsElement(this)

    private val T.hasAnimations: Boolean
        get() {
            repeat(placeablesCount) { index ->
                getParentData(index).specs?.let {
                    // found at least one
                    return true
                }
            }
            return false
        }

    private val LazyLayoutMeasuredItem.mainAxisOffset
        get() = getOffset(0).let { if (isVertical) it.y else it.x }

    private val LazyLayoutMeasuredItem.crossAxisOffset
        get() = getOffset(0).let { if (!isVertical) it.y else it.x }

    private inner class ItemInfo {
        /**
         * This array will have the same amount of elements as there are placeables on the item. If
         * the element is not null this means there are specs associated with the given placeable.
         */
        var animations = EmptyArray
            private set

        var constraints: Constraints? = null
        var crossAxisOffset: Int = 0
        var lane: Int = 0
        var span: Int = 1

        private val isRunningPlacement
            get() = animations.any { it?.isRunningMovingAwayAnimation == true }

        var layoutMinOffset = 0
            private set

        var layoutMaxOffset = 0
            private set

        fun updateAnimation(
            positionedItem: T,
            coroutineScope: CoroutineScope,
            graphicsContext: GraphicsContext,
            layoutMinOffset: Int,
            layoutMaxOffset: Int,
            crossAxisOffset: Int
        ) {
            if (!isRunningPlacement) {
                this.layoutMinOffset = layoutMinOffset
                this.layoutMaxOffset = layoutMaxOffset
            }
            for (i in positionedItem.placeablesCount until animations.size) {
                animations[i]?.release()
            }
            if (animations.size != positionedItem.placeablesCount) {
                animations = animations.copyOf(positionedItem.placeablesCount)
            }
            constraints = positionedItem.constraints
            this.crossAxisOffset = crossAxisOffset
            lane = positionedItem.lane
            span = positionedItem.span
            repeat(positionedItem.placeablesCount) { index ->
                val specs = positionedItem.getParentData(index).specs
                if (specs == null) {
                    animations[index]?.release()
                    animations[index] = null
                } else {
                    val animation =
                        animations[index]
                            ?: LazyLayoutItemAnimation(
                                    coroutineScope = coroutineScope,
                                    graphicsContext = graphicsContext,
                                    // until b/329417380 is fixed we have to trigger any
                                    // invalidation in
                                    // order for the layer properties change to be applied:
                                    onLayerPropertyChanged = { displayingNode?.invalidateDraw() }
                                )
                                .also { animations[index] = it }
                    animation.fadeInSpec = specs.fadeInSpec
                    animation.placementSpec = specs.placementSpec
                    animation.fadeOutSpec = specs.fadeOutSpec
                }
            }
        }
    }

    private data class DisplayingDisappearingItemsElement(
        private val animator: LazyLayoutItemAnimator<*>
    ) : ModifierNodeElement() {
        override fun create() = DisplayingDisappearingItemsNode(animator)

        override fun update(node: DisplayingDisappearingItemsNode) {
            node.setAnimator(animator)
        }

        override fun InspectorInfo.inspectableProperties() {
            name = "DisplayingDisappearingItemsElement"
        }
    }

    private data class DisplayingDisappearingItemsNode(
        private var animator: LazyLayoutItemAnimator<*>
    ) : Modifier.Node(), DrawModifierNode {
        override fun ContentDrawScope.draw() {
            animator.disappearingItems.fastForEach {
                val layer = it.layer ?: return@fastForEach
                val x = it.finalOffset.x.toFloat()
                val y = it.finalOffset.y.toFloat()
                translate(x - layer.topLeft.x.toFloat(), y - layer.topLeft.y.toFloat()) {
                    drawLayer(layer)
                }
            }
            drawContent()
        }

        override fun onAttach() {
            animator.displayingNode = this
        }

        override fun onDetach() {
            animator.reset()
        }

        fun setAnimator(animator: LazyLayoutItemAnimator<*>) {
            if (this.animator != animator) {
                if (node.isAttached) {
                    this.animator.reset()
                    animator.displayingNode = this
                    this.animator = animator
                }
            }
        }
    }
}

private val Any?.specs
    get() = this as? LazyLayoutAnimationSpecsNode

private val EmptyArray = emptyArray()




© 2015 - 2025 Weber Informatics LLC | Privacy Policy