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