xref: /aosp_15_r20/external/lottie/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieAnimatable.kt (revision bb5273fecd5c61b9ace70f9ff4fcd88f0e12e3f7)

<lambda>null1 package com.airbnb.lottie.compose
2 
3 import androidx.compose.animation.core.AnimationConstants
4 import androidx.compose.animation.core.withInfiniteAnimationFrameNanos
5 import androidx.compose.foundation.MutatorMutex
6 import androidx.compose.runtime.Composable
7 import androidx.compose.runtime.Stable
8 import androidx.compose.runtime.derivedStateOf
9 import androidx.compose.runtime.getValue
10 import androidx.compose.runtime.mutableStateOf
11 import androidx.compose.runtime.remember
12 import androidx.compose.runtime.setValue
13 import androidx.compose.runtime.withFrameNanos
14 import com.airbnb.lottie.LottieComposition
15 import kotlinx.coroutines.CancellationException
16 import kotlinx.coroutines.NonCancellable
17 import kotlinx.coroutines.ensureActive
18 import kotlinx.coroutines.job
19 import kotlinx.coroutines.withContext
20 import kotlin.coroutines.EmptyCoroutineContext
21 import kotlin.coroutines.coroutineContext
22 
23 /**
24  * Use this to create a [LottieAnimatable] in a composable.
25  *
26  * @see LottieAnimatable
27  */
28 @Composable
29 fun rememberLottieAnimatable(): LottieAnimatable = remember { LottieAnimatable() }
30 
31 /**
32  * Use this to create a [LottieAnimatable] outside of a composable such as a hoisted state class.
33  *
34  * @see rememberLottieAnimatable
35  * @see LottieAnimatable
36  */
LottieAnimatablenull37 fun LottieAnimatable(): LottieAnimatable = LottieAnimatableImpl()
38 
39 /**
40  * Reset the animation back to the minimum progress and first iteration.
41  */
42 suspend fun LottieAnimatable.resetToBeginning() {
43     snapTo(
44         progress = defaultProgress(composition, clipSpec, speed),
45         iteration = 1,
46     )
47 }
48 
49 /**
50  * [LottieAnimatable] is an extension of [LottieAnimationState] that contains imperative
51  * suspend functions to control animation playback.
52  *
53  * To create one, call:
54  * ```
55  * val animatable = rememberLottieAnimatable()
56  * ```
57  *
58  * This is the imperative version of [animateLottieCompositionAsState].
59  *
60  * [LottieAnimationState] ensures *mutual exclusiveness* on its animations. To
61  * achieve this, when a new animation is started via [animate] or [snapTo], any ongoing
62  * animation will be canceled via a [CancellationException]. Because of this, it is possible
63  * that your animation will not start synchronously. As a result, if you switch from animating
64  * one composition to another, it is not safe to render the second composition immediately after
65  * calling animate. Instead, you should always rely on [LottieAnimationState.composition] and
66  * [LottieAnimationState.progress].
67  *
68  * This class is comparable to [androidx.compose.animation.core.Animatable]. It is a relatively
69  * low-level API that gives maximum control over animations. In most cases, you can use
70  * [animateLottieCompositionAsState] which provides declarative APIs to create, update, and animate
71  * a [LottieComposition].
72  *
73  * @see animate
74  * @see snapTo
75  * @see animateLottieCompositionAsState
76  */
77 @Stable
78 interface LottieAnimatable : LottieAnimationState {
79     /**
80      * Snap to a specific point in an animation. This can be used to update the progress
81      * or iteration count of an ongoing animation. It will cancel any ongoing animations
82      * on this state class. To update and then resume an animation, call [animate] again with
83      * continueFromPreviousAnimate set to true after calling [snapTo].
84      *
85      * @param composition The [LottieComposition] that should be rendered.
86      *                    Defaults to [LottieAnimatable.composition].
87      * @param progress The progress that should be set.
88      *                 Defaults to [LottieAnimatable.progress]
89      * @param iteration Updates the current iteration count. This can be used to "rewind" or
90      *                  "fast-forward" an ongoing animation to a past/future iteration count.
91      *                   Defaults to [LottieAnimatable.iteration]
92      * @param resetLastFrameNanos [rememberLottieAnimatable] keeps track of the frame time of the most
93      *                            recent animation. When [animate] is called with continueFromPreviousAnimate
94      *                            set to true, a delta will be calculated from the most recent [animate] call
95      *                            to ensure that the original progress is unaffected by [snapTo] calls in the
96      *                            middle.
97      *                            Defaults to false if progress is not being snapped to.
98      *                            Defaults to true if progress is being snapped to.
99      */
snapTonull100     suspend fun snapTo(
101         composition: LottieComposition? = this.composition,
102         progress: Float = this.progress,
103         iteration: Int = this.iteration,
104         resetLastFrameNanos: Boolean = progress != this.progress,
105     )
106 
107     /**
108      * Animate a [LottieComposition].
109      *
110      * @param composition The [LottieComposition] that should be rendered.
111      * @param iteration The iteration to start the animation at. Defaults to 1 and carries over from previous animates.
112      * @param iterations The number of iterations to continue running for. Set to 1 to play one time
113      *                   set to [LottieConstants.IterateForever] to iterate forever. Can be set to arbitrary
114      *                   numbers. Defaults to 1 and carries over from previous animates.
115      * @param speed The speed at which the composition should be animated. Can be negative. Defaults to 1 and
116      *              carries over from previous animates.
117      * @param clipSpec An optional [LottieClipSpec] to trim the playback of the composition between two values.
118      *                 Defaults to null and carries over from previous animates.
119      * @param initialProgress An optional progress value that the animation should start at. Defaults to the
120      *                        starting progress as defined by the clipSpec and speed. Because the default value
121      *                        isn't the existing progress value, if you are resuming an animation, you
122      *                        probably want to set this to [progress].
123      * @param continueFromPreviousAnimate When set to true, instead of starting at the minimum progress,
124      *                                    the initial progress will be advanced in accordance to the amount
125      *                                    of time that has passed since the last frame was rendered.
126      * @param cancellationBehavior The behavior that this animation should have when cancelled. In most cases,
127      *                             you will want it to cancel immediately. However, if you have a state based
128      *                             transition and you want an animation to finish playing before moving on to
129      *                             the next one then you may want to set this to [LottieCancellationBehavior.OnIterationFinish].
130      * @param ignoreSystemAnimationsDisabled When set to true, the animation will animate even if animations
131      *                                       are disabled at the OS level.
132      *                                       Defaults to false.
133      * @param useCompositionFrameRate Lottie files can specify a target frame rate. By default, Lottie ignores it
134      *                                and re-renders on every frame. If that behavior is undesirable, you can set
135      *                                this to true to use the composition frame rate instead.
136      *                                Note: composition frame rates are usually lower than display frame rates
137      *                                so this will likely make your animation feel janky. However, it may be desirable
138      *                                for specific situations such as pixel art that are intended to have low frame rates.
139      */
140     suspend fun animate(
141         composition: LottieComposition?,
142         iteration: Int = this.iteration,
143         iterations: Int = this.iterations,
144         reverseOnRepeat: Boolean = this.reverseOnRepeat,
145         speed: Float = this.speed,
146         clipSpec: LottieClipSpec? = this.clipSpec,
147         initialProgress: Float = defaultProgress(composition, clipSpec, speed),
148         continueFromPreviousAnimate: Boolean = false,
149         cancellationBehavior: LottieCancellationBehavior = LottieCancellationBehavior.Immediately,
150         ignoreSystemAnimationsDisabled: Boolean = false,
151         useCompositionFrameRate: Boolean = false,
152     )
153 }
154 
155 @Stable
156 private class LottieAnimatableImpl : LottieAnimatable {
157     override var isPlaying: Boolean by mutableStateOf(false)
158         private set
159 
160     override val value: Float
161         get() = progress
162 
163     override var iteration: Int by mutableStateOf(1)
164         private set
165 
166     override var iterations: Int by mutableStateOf(1)
167         private set
168 
169     override var reverseOnRepeat: Boolean by mutableStateOf(false)
170         private set
171 
172     override var clipSpec: LottieClipSpec? by mutableStateOf(null)
173         private set
174 
175     override var speed: Float by mutableStateOf(1f)
176         private set
177 
178     override var useCompositionFrameRate: Boolean by mutableStateOf(false)
179         private set
180 
181     /**
182      * Inverse speed value is used to play the animation in reverse when [reverseOnRepeat] is true.
183      */
184     private val frameSpeed: Float by derivedStateOf {
185         if (reverseOnRepeat && iteration % 2 == 0) -speed else speed
186     }
187 
188     override var composition: LottieComposition? by mutableStateOf(null)
189         private set
190 
191     private var progressRaw: Float by mutableStateOf(0f)
192 
193     override var progress: Float by mutableStateOf(0f)
194         private set
195 
196     override var lastFrameNanos: Long by mutableStateOf(AnimationConstants.UnspecifiedTime)
197         private set
198 
199     private val endProgress: Float by derivedStateOf {
200         val c = composition
201         when {
202             c == null -> 0f
203             speed < 0 -> clipSpec?.getMinProgress(c) ?: 0f
204             else -> clipSpec?.getMaxProgress(c) ?: 1f
205         }
206     }
207 
208     override val isAtEnd: Boolean by derivedStateOf { iteration == iterations && progress == endProgress }
209 
210     private val mutex = MutatorMutex()
211 
212     override suspend fun snapTo(
213         composition: LottieComposition?,
214         progress: Float,
215         iteration: Int,
216         resetLastFrameNanos: Boolean,
217     ) {
218         mutex.mutate {
219             this.composition = composition
220             updateProgress(progress)
221             this.iteration = iteration
222             isPlaying = false
223             if (resetLastFrameNanos) {
224                 lastFrameNanos = AnimationConstants.UnspecifiedTime
225             }
226         }
227     }
228 
229     override suspend fun animate(
230         composition: LottieComposition?,
231         iteration: Int,
232         iterations: Int,
233         reverseOnRepeat: Boolean,
234         speed: Float,
235         clipSpec: LottieClipSpec?,
236         initialProgress: Float,
237         continueFromPreviousAnimate: Boolean,
238         cancellationBehavior: LottieCancellationBehavior,
239         ignoreSystemAnimationsDisabled: Boolean,
240         useCompositionFrameRate: Boolean,
241     ) {
242         mutex.mutate {
243             this.iteration = iteration
244             this.iterations = iterations
245             this.reverseOnRepeat = reverseOnRepeat
246             this.speed = speed
247             this.clipSpec = clipSpec
248             this.composition = composition
249             updateProgress(initialProgress)
250             this.useCompositionFrameRate = useCompositionFrameRate
251             if (!continueFromPreviousAnimate) lastFrameNanos = AnimationConstants.UnspecifiedTime
252             if (composition == null) {
253                 isPlaying = false
254                 return@mutate
255             } else if (speed.isInfinite()) {
256                 updateProgress(endProgress)
257                 isPlaying = false
258                 this.iteration = iterations
259                 return@mutate
260             }
261 
262             isPlaying = true
263             try {
264                 val context = when (cancellationBehavior) {
265                     LottieCancellationBehavior.OnIterationFinish -> NonCancellable
266                     LottieCancellationBehavior.Immediately -> EmptyCoroutineContext
267                 }
268                 val parentJob = coroutineContext.job
269                 withContext(context) {
270                     while (true) {
271                         val actualIterations = when (cancellationBehavior) {
272                             LottieCancellationBehavior.OnIterationFinish -> {
273                                 if (parentJob.isActive) iterations else iteration
274                             }
275                             else -> iterations
276                         }
277                         if (!doFrame(actualIterations)) break
278                     }
279                 }
280                 coroutineContext.ensureActive()
281             } finally {
282                 isPlaying = false
283             }
284         }
285     }
286 
287     private suspend fun doFrame(iterations: Int): Boolean {
288         return if (iterations == LottieConstants.IterateForever) {
289             // We use withInfiniteAnimationFrameNanos because it allows tests to add a CoroutineContext
290             // element that will cancel infinite transitions instead of preventing composition from ever going idle.
291             withInfiniteAnimationFrameNanos { frameNanos ->
292                 onFrame(iterations, frameNanos)
293             }
294         } else {
295             withFrameNanos { frameNanos ->
296                 onFrame(iterations, frameNanos)
297             }
298         }
299     }
300 
301     private fun onFrame(iterations: Int, frameNanos: Long): Boolean {
302         val composition = composition ?: return true
303         val dNanos = if (lastFrameNanos == AnimationConstants.UnspecifiedTime) 0L else (frameNanos - lastFrameNanos)
304         lastFrameNanos = frameNanos
305 
306         val minProgress = clipSpec?.getMinProgress(composition) ?: 0f
307         val maxProgress = clipSpec?.getMaxProgress(composition) ?: 1f
308 
309         val dProgress = dNanos / 1_000_000 / composition.duration * frameSpeed
310         val progressPastEndOfIteration = when {
311             frameSpeed < 0 -> minProgress - (progressRaw + dProgress)
312             else -> progressRaw + dProgress - maxProgress
313         }
314         if (progressPastEndOfIteration < 0f) {
315             updateProgress(progressRaw.coerceIn(minProgress, maxProgress) + dProgress)
316         } else {
317             val durationProgress = maxProgress - minProgress
318             val dIterations = (progressPastEndOfIteration / durationProgress).toInt() + 1
319 
320             if (iteration + dIterations > iterations) {
321                 updateProgress(endProgress)
322                 iteration = iterations
323                 return false
324             }
325             iteration += dIterations
326             val progressPastEndRem = progressPastEndOfIteration - (dIterations - 1) * durationProgress
327             updateProgress(
328                 when {
329                     frameSpeed < 0 -> maxProgress - progressPastEndRem
330                     else -> minProgress + progressPastEndRem
331                 }
332             )
333         }
334 
335         return true
336     }
337 
338     private fun Float.roundToCompositionFrameRate(composition: LottieComposition?): Float {
339         composition ?: return this
340         val frameRate = composition.frameRate
341         val interval = 1 / frameRate
342         return this - this % interval
343     }
344 
345     private fun updateProgress(progress: Float) {
346         this.progressRaw = progress
347         this.progress = if (useCompositionFrameRate) progress.roundToCompositionFrameRate(composition) else progress
348     }
349 }
350 
defaultProgressnull351 private fun defaultProgress(composition: LottieComposition?, clipSpec: LottieClipSpec?, speed: Float): Float {
352     return when {
353         speed < 0 && composition == null -> 1f
354         composition == null -> 0f
355         speed < 0 -> clipSpec?.getMaxProgress(composition) ?: 1f
356         else -> clipSpec?.getMinProgress(composition) ?: 0f
357     }
358 }
359