1 /*
2  * Copyright 2024 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.compose.foundation.lazy.layout
18 
19 import androidx.compose.animation.core.Animatable
20 import androidx.compose.animation.core.FiniteAnimationSpec
21 import androidx.compose.animation.core.Spring
22 import androidx.compose.animation.core.SpringSpec
23 import androidx.compose.animation.core.VectorConverter
24 import androidx.compose.animation.core.VisibilityThreshold
25 import androidx.compose.animation.core.spring
26 import androidx.compose.runtime.getValue
27 import androidx.compose.runtime.mutableStateOf
28 import androidx.compose.runtime.setValue
29 import androidx.compose.ui.Modifier
30 import androidx.compose.ui.graphics.GraphicsContext
31 import androidx.compose.ui.graphics.layer.GraphicsLayer
32 import androidx.compose.ui.node.ModifierNodeElement
33 import androidx.compose.ui.node.ParentDataModifierNode
34 import androidx.compose.ui.platform.InspectorInfo
35 import androidx.compose.ui.unit.Density
36 import androidx.compose.ui.unit.IntOffset
37 import kotlinx.coroutines.CancellationException
38 import kotlinx.coroutines.CoroutineScope
39 import kotlinx.coroutines.launch
40 
41 internal class LazyLayoutItemAnimation(
42     private val coroutineScope: CoroutineScope,
43     private val graphicsContext: GraphicsContext? = null,
<lambda>null44     private val onLayerPropertyChanged: () -> Unit = {}
45 ) {
46     var fadeInSpec: FiniteAnimationSpec<Float>? = null
47     var placementSpec: FiniteAnimationSpec<IntOffset>? = null
48     var fadeOutSpec: FiniteAnimationSpec<Float>? = null
49 
50     var isRunningMovingAwayAnimation = false
51         private set
52 
53     /**
54      * Returns true when the placement animation is currently in progress so the parent should
55      * continue composing this item.
56      */
57     var isPlacementAnimationInProgress by mutableStateOf(false)
58         private set
59 
60     /** Returns true when the appearance animation is currently in progress. */
61     var isAppearanceAnimationInProgress by mutableStateOf(false)
62         private set
63 
64     /** Returns true when the disappearance animation is currently in progress. */
65     var isDisappearanceAnimationInProgress by mutableStateOf(false)
66         private set
67 
68     /** Returns true when the disappearance animation has been finished. */
69     var isDisappearanceAnimationFinished by mutableStateOf(false)
70         private set
71 
72     /**
73      * This property is managed by the animation manager and is not directly used by this class. It
74      * represents the last known offset of this item in the lazy layout coordinate space. It will be
75      * updated on every scroll and is allowing the manager to track when the item position changes
76      * not because of the scroll event in order to start the animation. When there is an active
77      * animation it represents the final/target offset.
78      */
79     var rawOffset: IntOffset = NotInitialized
80 
81     /**
82      * The final offset the placeable associated with this animations was placed at. Unlike
83      * [rawOffset] it takes into account things like reverse layout and content padding.
84      */
85     var finalOffset: IntOffset = IntOffset.Zero
86 
87     /** Current [GraphicsLayer]. It will be set to null in [release]. */
88     var layer: GraphicsLayer? = graphicsContext?.createGraphicsLayer()
89         private set
90 
91     private val placementDeltaAnimation = Animatable(IntOffset.Zero, IntOffset.VectorConverter)
92 
93     private val visibilityAnimation = Animatable(1f, Float.VectorConverter)
94 
95     /**
96      * Current delta to apply for a placement offset. Updates every animation frame. The settled
97      * value is [IntOffset.Zero] so the animation is always targeting this value.
98      */
99     var placementDelta by mutableStateOf(IntOffset.Zero)
100         private set
101 
102     /** Cancels the ongoing placement animation if there is one. */
cancelPlacementAnimationnull103     fun cancelPlacementAnimation() {
104         if (isPlacementAnimationInProgress) {
105             coroutineScope.launch {
106                 placementDeltaAnimation.snapTo(IntOffset.Zero)
107                 placementDelta = IntOffset.Zero
108                 isPlacementAnimationInProgress = false
109             }
110         }
111     }
112 
113     /**
114      * Tracks the offset of the item in the lookahead pass. When set, this is the animation target
115      * that placementDelta should be applied to.
116      */
117     var lookaheadOffset: IntOffset = NotInitialized
118 
119     /** Animate the placement by the given [delta] offset. */
animatePlacementDeltanull120     fun animatePlacementDelta(delta: IntOffset, isMovingAway: Boolean) {
121         val spec = placementSpec ?: return
122         val totalDelta = placementDelta - delta
123         placementDelta = totalDelta
124         isPlacementAnimationInProgress = true
125         isRunningMovingAwayAnimation = isMovingAway
126         coroutineScope.launch {
127             try {
128                 val finalSpec =
129                     if (placementDeltaAnimation.isRunning) {
130                         // when interrupted, use the default spring, unless the spec is a spring.
131                         if (spec is SpringSpec<IntOffset>) {
132                             spec
133                         } else {
134                             InterruptionSpec
135                         }
136                     } else {
137                         spec
138                     }
139                 if (!placementDeltaAnimation.isRunning) {
140                     // if not running we can snap to the initial value and animate to zero
141                     placementDeltaAnimation.snapTo(totalDelta)
142                     onLayerPropertyChanged()
143                 }
144                 // if animation is not currently running the target will be zero, otherwise
145                 // we have to continue the animation from the current value, but keep the needed
146                 // total delta for the new animation.
147                 val animationTarget = placementDeltaAnimation.value - totalDelta
148                 placementDeltaAnimation.animateTo(animationTarget, finalSpec) {
149                     // placementDelta is calculated as if we always animate to target equal to zero
150                     placementDelta = value - animationTarget
151                     onLayerPropertyChanged()
152                 }
153 
154                 isPlacementAnimationInProgress = false
155                 isRunningMovingAwayAnimation = false
156             } catch (_: CancellationException) {
157                 // we don't reset inProgress in case of cancellation as it means
158                 // there is a new animation started which would reset it later
159             }
160         }
161     }
162 
animateAppearancenull163     fun animateAppearance() {
164         val layer = layer
165         val spec = fadeInSpec
166         if (isAppearanceAnimationInProgress || spec == null || layer == null) {
167             if (isDisappearanceAnimationInProgress) {
168                 // we have an active disappearance, and then appearance was requested, but the user
169                 // provided null spec for the appearance. we need to immediately switch to 1f
170                 layer?.alpha = 1f
171                 coroutineScope.launch { visibilityAnimation.snapTo(1f) }
172             }
173             return
174         }
175         isAppearanceAnimationInProgress = true
176         val shouldResetValue = !isDisappearanceAnimationInProgress
177         if (shouldResetValue) {
178             layer.alpha = 0f
179         }
180         coroutineScope.launch {
181             try {
182                 if (shouldResetValue) {
183                     visibilityAnimation.snapTo(0f)
184                 }
185                 visibilityAnimation.animateTo(1f, spec) {
186                     layer.alpha = value
187                     onLayerPropertyChanged()
188                 }
189             } finally {
190                 isAppearanceAnimationInProgress = false
191             }
192         }
193     }
194 
animateDisappearancenull195     fun animateDisappearance() {
196         val layer = layer
197         val spec = fadeOutSpec
198         if (layer == null || isDisappearanceAnimationInProgress || spec == null) {
199             return
200         }
201         isDisappearanceAnimationInProgress = true
202         coroutineScope.launch {
203             try {
204                 visibilityAnimation.animateTo(0f, spec) {
205                     layer.alpha = value
206                     onLayerPropertyChanged()
207                 }
208                 isDisappearanceAnimationFinished = true
209             } finally {
210                 isDisappearanceAnimationInProgress = false
211             }
212         }
213     }
214 
releasenull215     fun release() {
216         if (isPlacementAnimationInProgress) {
217             isPlacementAnimationInProgress = false
218             coroutineScope.launch { placementDeltaAnimation.stop() }
219         }
220         if (isAppearanceAnimationInProgress) {
221             isAppearanceAnimationInProgress = false
222             coroutineScope.launch { visibilityAnimation.stop() }
223         }
224         if (isDisappearanceAnimationInProgress) {
225             isDisappearanceAnimationInProgress = false
226             coroutineScope.launch { visibilityAnimation.stop() }
227         }
228         isRunningMovingAwayAnimation = false
229         placementDelta = IntOffset.Zero
230         rawOffset = NotInitialized
231         layer?.let { graphicsContext?.releaseGraphicsLayer(it) }
232         layer = null
233         fadeInSpec = null
234         fadeOutSpec = null
235         placementSpec = null
236     }
237 
238     companion object {
239         val NotInitialized = IntOffset(Int.MAX_VALUE, Int.MAX_VALUE)
240     }
241 }
242 
243 internal data class LazyLayoutAnimateItemElement(
244     private val fadeInSpec: FiniteAnimationSpec<Float>?,
245     private val placementSpec: FiniteAnimationSpec<IntOffset>?,
246     private val fadeOutSpec: FiniteAnimationSpec<Float>?
247 ) : ModifierNodeElement<LazyLayoutAnimationSpecsNode>() {
248 
createnull249     override fun create(): LazyLayoutAnimationSpecsNode =
250         LazyLayoutAnimationSpecsNode(fadeInSpec, placementSpec, fadeOutSpec)
251 
252     override fun update(node: LazyLayoutAnimationSpecsNode) {
253         node.fadeInSpec = fadeInSpec
254         node.placementSpec = placementSpec
255         node.fadeOutSpec = fadeOutSpec
256     }
257 
inspectablePropertiesnull258     override fun InspectorInfo.inspectableProperties() {
259         name = "animateItem"
260         properties["fadeInSpec"] = fadeInSpec
261         properties["placementSpec"] = placementSpec
262         properties["fadeOutSpec"] = fadeOutSpec
263     }
264 }
265 
266 internal class LazyLayoutAnimationSpecsNode(
267     var fadeInSpec: FiniteAnimationSpec<Float>?,
268     var placementSpec: FiniteAnimationSpec<IntOffset>?,
269     var fadeOutSpec: FiniteAnimationSpec<Float>?
270 ) : Modifier.Node(), ParentDataModifierNode {
271 
modifyParentDatanull272     override fun Density.modifyParentData(parentData: Any?): Any = this@LazyLayoutAnimationSpecsNode
273 }
274 
275 /** We switch to this spec when a duration based animation is being interrupted. */
276 private val InterruptionSpec =
277     spring(
278         stiffness = Spring.StiffnessMediumLow,
279         visibilityThreshold = IntOffset.VisibilityThreshold
280     )
281