WIP
diff --git a/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieAnimation.kt b/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieAnimation.kt
index 7c30b6c..21139ed 100644
--- a/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieAnimation.kt
+++ b/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieAnimation.kt
@@ -159,13 +159,10 @@
fun LottieAnimation(
compositionSpec: LottieCompositionSpec,
modifier: Modifier = Modifier,
- isPlaying: Boolean = true,
- restartOnPlay: Boolean = true,
+ initialIsPlaying: Boolean = true,
repeatCount: Int = 1,
clipSpec: LottieClipSpec? = null,
speed: Float = 1f,
- onRepeat: ((repeatCount: Int) -> Unit)? = null,
- onFinished: (() -> Unit)? = null,
imageAssetsFolder: String? = null,
imageAssetDelegate: ImageAssetDelegate? = null,
outlineMasksAndMattes: Boolean = false,
@@ -176,13 +173,10 @@
LottieAnimation(
composition,
modifier,
- isPlaying,
- restartOnPlay,
+ initialIsPlaying,
clipSpec,
speed,
repeatCount,
- onRepeat,
- onFinished,
imageAssetsFolder,
imageAssetDelegate,
outlineMasksAndMattes,
@@ -202,13 +196,10 @@
fun LottieAnimation(
composition: LottieComposition?,
modifier: Modifier = Modifier,
- isPlaying: Boolean = true,
- restartOnPlay: Boolean = true,
+ initialIsPlaying: Boolean = true,
clipSpec: LottieClipSpec? = null,
speed: Float = 1f,
repeatCount: Int = 1,
- onRepeat: ((repeatCount: Int) -> Unit)? = null,
- onFinished: (() -> Unit)? = null,
imageAssetsFolder: String? = null,
imageAssetDelegate: ImageAssetDelegate? = null,
outlineMasksAndMattes: Boolean = false,
@@ -217,13 +208,10 @@
) {
val progress by animateLottieComposition(
composition,
- isPlaying,
- restartOnPlay,
+ initialIsPlaying,
clipSpec,
speed,
repeatCount,
- onRepeat,
- onFinished,
)
LottieAnimation(
composition,
diff --git a/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieConstants.kt b/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieConstants.kt
new file mode 100644
index 0000000..12f308e
--- /dev/null
+++ b/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieConstants.kt
@@ -0,0 +1,8 @@
+package com.airbnb.lottie.compose
+
+object LottieConstants {
+ /**
+ * Use with [animateLottieComposition]#repeatCount to repeat forever.
+ */
+ const val RepeatForever = Integer.MAX_VALUE
+}
\ No newline at end of file
diff --git a/lottie-compose/src/main/java/com/airbnb/lottie/compose/animateLottieComposition.kt b/lottie-compose/src/main/java/com/airbnb/lottie/compose/animateLottieComposition.kt
index f3ac523..b70e9f1 100644
--- a/lottie-compose/src/main/java/com/airbnb/lottie/compose/animateLottieComposition.kt
+++ b/lottie-compose/src/main/java/com/airbnb/lottie/compose/animateLottieComposition.kt
@@ -3,8 +3,55 @@
import androidx.compose.animation.core.*
import androidx.compose.runtime.*
import com.airbnb.lottie.LottieComposition
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.first
import java.util.concurrent.TimeUnit
+class LottieAnimationState internal constructor(initialIsPlaying: Boolean) : State<Float> {
+ var isPlaying: Boolean by mutableStateOf(initialIsPlaying)
+ internal set
+
+ override var value: Float by mutableStateOf(0f)
+ internal set
+
+ var currentRepeatCount: Int by mutableStateOf(1)
+ internal set
+
+ internal val actionChannel = Channel<LottieAnimationAction>()
+
+ internal val onFinished = MutableSharedFlow<Long>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
+
+ suspend fun restart() {
+ actionChannel.send(LottieAnimationAction.Reset)
+ actionChannel.send(LottieAnimationAction.Resume)
+ }
+
+ suspend fun toggleIsPlaying() {
+ actionChannel.send(if (isPlaying) LottieAnimationAction.Pause else LottieAnimationAction.Resume)
+ }
+
+ suspend fun pause() {
+ actionChannel.send(LottieAnimationAction.Pause)
+ }
+
+ suspend fun resume() {
+ actionChannel.send(LottieAnimationAction.Resume)
+ }
+
+ suspend fun snapTo(progress: Float) {
+ actionChannel.send(LottieAnimationAction.SnapTo(progress))
+ }
+
+ /**
+ * Suspends until the animation finishes and then returns the last frame time nanos.
+ */
+ suspend fun awaitFinished(): Long {
+ return onFinished.first()
+ }
+}
+
/**
* Returns a mutable state representing the progress of an animation.
*
@@ -30,9 +77,8 @@
* @param speed The speed the animation should play at. Numbers larger than one will speed it up.
* Numbers between 0 and 1 will slow it down. Numbers less than 0 will play it backwards.
* @param repeatCount The number of times the animation should repeat before stopping. It must be
- * a positive number. [Integer.MAX_VALUE] can be used to repeat forever.
- * @param onRepeat An optional callback to be notified every time the animation repeats. Return whether
- * or not the animation should continue to repeat.
+ * a positive number. [LottieConstants.repeatForever] can be used to repeat forever.
+ * @param onRepeat An optional callback to be notified every time the animation repeats.
* @param onFinished An optional callback that is invoked when animation completes. Note that the isPlaying
* parameter you pass in may still be true. If you want to restart the animation, increase the
* repeatCount or change isPlaying to false and then true again.
@@ -40,43 +86,65 @@
@Composable
fun animateLottieComposition(
composition: LottieComposition?,
- isPlaying: Boolean = true,
- restartOnPlay: Boolean = true,
+ initialIsPlaying: Boolean = true,
clipSpec: LottieClipSpec? = null,
speed: Float = 1f,
repeatCount: Int = 1,
- onRepeat: ((repeatCount: Int) -> Unit)? = null,
- onFinished: (() -> Unit)? = null,
-): MutableState<Float> {
+): LottieAnimationState {
require(repeatCount > 0) { "Repeat count must be a positive number ($repeatCount)." }
require(speed != 0f) { "Speed must not be 0" }
require(speed.isFinite()) { "Speed must be a finite number. It is $speed." }
-
- val progress = remember { mutableStateOf(0f) }
-
- var currentRepeatCount by remember { mutableStateOf(0) }
- val currentOnRepeat by rememberUpdatedState(onRepeat)
- val currentOnFinished by rememberUpdatedState(onFinished)
+ val state = remember { LottieAnimationState(initialIsPlaying) }
LaunchedEffect(composition) {
- progress.value = when (composition) {
+ state.value = when (composition) {
null -> 0f
- else -> if (speed >= 0) clipSpec?.getMinProgress(composition) ?: 0f else clipSpec?.getMaxProgress(composition) ?: 1f
+ else -> when {
+ speed >= 0 -> clipSpec?.getMinProgress(composition) ?: 0f
+ else -> clipSpec?.getMaxProgress(composition) ?: 1f
+ }
}
- currentRepeatCount = 0
+ state.currentRepeatCount = 0
}
- LaunchedEffect(composition, isPlaying, repeatCount, clipSpec, speed) {
- if (!isPlaying || composition == null) return@LaunchedEffect
+ LaunchedEffect(state) {
+ for (action in state.actionChannel) {
+ when (action) {
+ LottieAnimationAction.Reset -> {
+ state.value = when {
+ composition == null -> 0f
+ speed > 0 -> clipSpec?.getMinProgress(composition) ?: 0f
+ else -> clipSpec?.getMaxProgress(composition) ?: 1f
+ }
+ }
+ LottieAnimationAction.Pause -> {
+ state.isPlaying = false
+ }
+ LottieAnimationAction.Resume -> {
+ state.isPlaying = true
+ }
+ is LottieAnimationAction.SnapTo -> {
+ state.value = when {
+ composition == null -> 0f
+ clipSpec == null -> action.progress
+ else -> action.progress.coerceIn(
+ clipSpec.getMinProgress(composition),
+ clipSpec.getMaxProgress(composition),
+ )
+ }
+ }
+ }
+ }
+ }
+
+ LaunchedEffect(composition, state.isPlaying, repeatCount, clipSpec, speed) {
+ if (!state.isPlaying || composition == null) return@LaunchedEffect
val minProgress = clipSpec?.getMinProgress(composition) ?: 0f
val maxProgress = clipSpec?.getMaxProgress(composition) ?: 1f
- if (speed > 0 && (progress.value == 1f || restartOnPlay)) {
- progress.value = minProgress
- } else if (speed < 0 && (progress.value == 0f || restartOnPlay)) {
- progress.value = maxProgress
- }
- if (restartOnPlay || currentRepeatCount >= repeatCount) {
- currentRepeatCount = 0
+ if (speed >= 0 && (state.value == 1f)) {
+ state.value = minProgress
+ } else if (speed < 0 && (state.value == 0f)) {
+ state.value = maxProgress
}
var lastFrameTime = withFrameNanos { it }
var done = false
@@ -85,19 +153,17 @@
val dTime = (frameTime - lastFrameTime) / TimeUnit.MILLISECONDS.toNanos(1).toFloat()
lastFrameTime = frameTime
val dProgress = (dTime * speed) / composition.duration
- val rawProgress = minProgress + ((progress.value - minProgress) + dProgress)
+ val rawProgress = minProgress + ((state.value - minProgress) + dProgress)
if (speed > 0 && rawProgress > maxProgress) {
- currentRepeatCount++
- currentOnRepeat?.invoke(repeatCount)
+ state.currentRepeatCount++
} else if (speed < 0 && rawProgress < minProgress) {
- currentRepeatCount++
- currentOnRepeat?.invoke(repeatCount)
+ state.currentRepeatCount++
}
- done = if (currentRepeatCount < repeatCount && !rawProgress.isInfinite()) {
- progress.value = minProgress + ((rawProgress - minProgress) fmod (maxProgress - minProgress))
+ done = if (state.currentRepeatCount < repeatCount && !rawProgress.isInfinite()) {
+ state.value = minProgress + ((rawProgress - minProgress) fmod (maxProgress - minProgress))
false
} else {
- progress.value = when {
+ state.value = when {
speed >= 0 -> clipSpec?.getMaxProgress(composition) ?: 1f
else -> clipSpec?.getMinProgress(composition) ?: 0f
}
@@ -105,13 +171,20 @@
}
}
}
- currentOnFinished?.invoke()
+ state.onFinished.emit(lastFrameTime)
}
- return progress
+ return state
}
/**
* Floor mod instead of % which is remainder. This allows negative speeds to properly wrap around to
* the max progress.
*/
-private infix fun Float.fmod(other: Float) = ((this % other) + other) % other
\ No newline at end of file
+private infix fun Float.fmod(other: Float) = ((this % other) + other) % other
+
+internal sealed class LottieAnimationAction {
+ object Reset : LottieAnimationAction()
+ object Pause : LottieAnimationAction()
+ object Resume : LottieAnimationAction()
+ class SnapTo(val progress: Float) : LottieAnimationAction()
+}
\ No newline at end of file
diff --git a/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/examples/BasicUsageExamplesPage.kt b/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/examples/BasicUsageExamplesPage.kt
index acf1eb6..629fade 100644
--- a/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/examples/BasicUsageExamplesPage.kt
+++ b/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/examples/BasicUsageExamplesPage.kt
@@ -11,6 +11,7 @@
import androidx.compose.ui.unit.dp
import com.airbnb.lottie.compose.*
import com.airbnb.lottie.sample.compose.R
+import kotlinx.coroutines.launch
@Composable
fun BasicUsageExamplesPage() {
@@ -62,7 +63,7 @@
private fun Example2() {
LottieAnimation(
LottieCompositionSpec.RawRes(R.raw.heart),
- repeatCount = Integer.MAX_VALUE,
+ repeatCount = LottieConstants.RepeatForever,
)
}
@@ -73,7 +74,7 @@
private fun Example3() {
LottieAnimation(
LottieCompositionSpec.RawRes(R.raw.heart),
- repeatCount = Integer.MAX_VALUE,
+ repeatCount = LottieConstants.RepeatForever,
clipSpec = LottieClipSpec.MinAndMaxProgress(0.5f, 0.75f),
)
}
@@ -118,7 +119,7 @@
val composition by lottieComposition(LottieCompositionSpec.RawRes(R.raw.heart))
val progress by animateLottieComposition(
composition,
- repeatCount = Integer.MAX_VALUE,
+ repeatCount = LottieConstants.RepeatForever,
)
LottieAnimation(
composition,
@@ -131,16 +132,16 @@
*/
@Composable
private fun Example7() {
- var isPlaying by remember { mutableStateOf(false) }
+ val scope = rememberCoroutineScope()
LottieAnimation(
LottieCompositionSpec.RawRes(R.raw.heart),
- repeatCount = Integer.MAX_VALUE,
- // When this is true, it it will start from 0 every time it is played again.
- // When this is false, it will resume from the progress it was pause at.
- restartOnPlay = false,
- isPlaying = isPlaying,
+ repeatCount = LottieConstants.RepeatForever,
modifier = Modifier
- .clickable { isPlaying = !isPlaying }
+ .clickable {
+ scope.launch {
+
+ }
+ }
)
}
diff --git a/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/player/PlayerPage.kt b/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/player/PlayerPage.kt
index cab2071..5bf6e47 100644
--- a/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/player/PlayerPage.kt
+++ b/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/player/PlayerPage.kt
@@ -5,6 +5,9 @@
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.*
+import androidx.compose.foundation.interaction.DragInteraction
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
@@ -40,13 +43,14 @@
import com.airbnb.lottie.sample.compose.utils.maybeBackground
import com.airbnb.lottie.sample.compose.utils.maybeDrawBorder
import com.airbnb.lottie.sample.compose.utils.toDummyBitmap
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.launch
import kotlin.math.ceil
import kotlin.math.roundToInt
@Stable
class PlayerPageState {
- var isPlaying by mutableStateOf(true)
- var repeatCount by mutableStateOf(Integer.MAX_VALUE)
+ var repeatCount by mutableStateOf(LottieConstants.RepeatForever)
var speed by mutableStateOf(1f)
var outlineMasksAndMattes by mutableStateOf(false)
var applyOpacityToLayers by mutableStateOf(false)
@@ -167,13 +171,12 @@
ImageAssetDelegate { if (it.hasBitmap()) null else it.toDummyBitmap(dummyBitmapStrokeWidth) }
}
}
- val progress = animateLottieComposition(
+ val animationState = animateLottieComposition(
compositionResult(),
- state.isPlaying,
- restartOnPlay = false,
+ initialIsPlaying = true,
repeatCount = state.repeatCount,
speed = state.speed,
- ) { state.isPlaying = false }
+ )
Column(
verticalArrangement = Arrangement.SpaceBetween,
@@ -188,7 +191,7 @@
) {
LottieAnimation(
compositionResult(),
- progress.value,
+ animationState.value,
imageAssetDelegate = imageAssetDelegate,
modifier = Modifier
.fillMaxSize()
@@ -213,7 +216,7 @@
)
}
ExpandVisibility(!state.focusMode) {
- PlayerControlsRow(compositionResult(), progress, state)
+ PlayerControlsRow(compositionResult(), animationState, state)
}
ExpandVisibility(!state.focusMode) {
Toolbar(state)
@@ -224,15 +227,16 @@
@Composable
private fun PlayerControlsRow(
composition: LottieComposition?,
- progress: MutableState<Float>,
+ animationState: LottieAnimationState,
state: PlayerPageState,
) {
+ val scope = rememberCoroutineScope()
val totalTime = ((composition?.duration ?: 0L / state.speed) / 1000.0)
val totalTimeFormatted = ("%.1f").format(totalTime)
- val progressFormatted = ("%.1f").format(progress.value * totalTime)
+ val progressFormatted = ("%.1f").format(animationState.value * totalTime)
- val frame = composition?.getFrameForProgress(progress.value)?.roundToInt() ?: 0
+ val frame = composition?.getFrameForProgress(animationState.value)?.roundToInt() ?: 0
val durationFrames = ceil(composition?.durationFrames ?: 0f).roundToInt()
Box(
modifier = Modifier
@@ -245,10 +249,14 @@
contentAlignment = Alignment.Center
) {
IconButton(
- onClick = { state.isPlaying = !state.isPlaying },
+ onClick = {
+ scope.launch {
+ animationState.toggleIsPlaying()
+ }
+ },
) {
Icon(
- if (state.isPlaying) Icons.Filled.Pause
+ if (animationState.isPlaying) Icons.Filled.Pause
else Icons.Filled.PlayArrow,
contentDescription = null
)
@@ -261,17 +269,16 @@
.padding(top = 48.dp, bottom = 8.dp)
)
}
- Slider(
- value = progress.value,
- onValueChange = { progress.value = it },
+ AnimationSlider(
+ animationState,
modifier = Modifier.weight(1f)
)
IconButton(onClick = {
- state.repeatCount = if (state.repeatCount == Integer.MAX_VALUE) 1 else Integer.MAX_VALUE
+ state.repeatCount = if (state.repeatCount == LottieConstants.RepeatForever) 1 else LottieConstants.RepeatForever
}) {
Icon(
Icons.Filled.Repeat,
- tint = if (state.repeatCount == Integer.MAX_VALUE) Teal else Color.Black,
+ tint = if (state.repeatCount == LottieConstants.RepeatForever) Teal else Color.Black,
contentDescription = null
)
}
@@ -288,6 +295,44 @@
}
@Composable
+private fun AnimationSlider(
+ animationState: LottieAnimationState,
+ modifier: Modifier = Modifier,
+) {
+ val scope = rememberCoroutineScope()
+ val interactionSource = remember { MutableInteractionSource() }
+ var isInteracting by remember { mutableStateOf(false) }
+
+ LaunchedEffect(interactionSource) {
+ interactionSource.interactions.collect { interaction ->
+ isInteracting = when (interaction) {
+ is PressInteraction.Press, is DragInteraction.Start -> true
+ else -> false
+ }
+ }
+ }
+
+ LaunchedEffect(isInteracting) {
+ if (isInteracting) {
+ animationState.pause()
+ } else {
+ animationState.resume()
+ }
+ }
+
+ Slider(
+ value = animationState.value,
+ interactionSource = interactionSource,
+ onValueChange = { progress ->
+ scope.launch {
+ animationState.snapTo(progress)
+ }
+ },
+ modifier = modifier,
+ )
+}
+
+@Composable
private fun SpeedToolbar(
state: PlayerPageState,
) {