[Compose] Added the ability to set dynamic properties (#1831)

Example usage can be seen on DynamicPropertiesExamplesPage.kt.

Thanks to Compose's impressive snapshot system, states that are read during the drawing pass via the dynamic properties callback are automatically registered so invalidation happens correctly by default.
diff --git a/CHANGELOG_COMPOSE.md b/CHANGELOG_COMPOSE.md
index 2e6fe0d..0cbd03d 100644
--- a/CHANGELOG_COMPOSE.md
+++ b/CHANGELOG_COMPOSE.md
@@ -16,6 +16,8 @@
 There are overloaded version of `LottieAnimation` that merge the properties for convenience. Please
 refer to the docs for `LottieAnimation`, `LottieAnimatable`, `animateLottieCompositionAsState`
 and `rememberLottieComposition` for more information.
+* Added the ability to clip the progress bounds of an animation.
+* Added the ability to set and control dynamic properties.
 
 # 1.0.0-beta07-1
 * Compatible with Jetpack Compose Beta 07
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 4a8a6c9..5ea2c46 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
@@ -70,6 +70,8 @@
  *                         features so it defaults to off. The only way to know if your animation will work
  *                         well with merge paths or not is to try it. If your animation has merge paths and
  *                         doesn't render correctly, please file an issue.
+ * @param dynamicProperties Allows you to change the properties of an animation dynamically. To use them, use
+ *                          [rememberLottieDynamicProperties]. Refer to its docs for more info.
  */
 @Composable
 fun LottieAnimation(
@@ -81,9 +83,11 @@
     outlineMasksAndMattes: Boolean = false,
     applyOpacityToLayers: Boolean = false,
     enableMergePaths: Boolean = false,
+    dynamicProperties: LottieDynamicProperties? = null,
 ) {
     val drawable = remember { LottieDrawable() }
     var imageAssetManager by remember { mutableStateOf<ImageAssetManager?>(null) }
+    var setDynamicProperties: LottieDynamicProperties? by remember { mutableStateOf(null) }
 
     if (composition == null || composition.duration == 0f) return Box(modifier)
 
@@ -105,6 +109,11 @@
                 scale(size.width / composition.bounds.width().toFloat(), size.height / composition.bounds.height().toFloat(), Offset.Zero)
             }) {
                 drawable.composition = composition
+                if (dynamicProperties !== setDynamicProperties) {
+                    setDynamicProperties?.removeFrom(drawable)
+                    dynamicProperties?.addTo(drawable)
+                    setDynamicProperties = dynamicProperties
+                }
                 drawable.setOutlineMasksAndMattes(outlineMasksAndMattes)
                 drawable.isApplyingOpacityToLayersEnabled = applyOpacityToLayers
                 drawable.enableMergePathsForKitKatAndAbove(enableMergePaths)
@@ -137,6 +146,7 @@
     outlineMasksAndMattes: Boolean = false,
     applyOpacityToLayers: Boolean = false,
     enableMergePaths: Boolean = false,
+    dynamicProperties: LottieDynamicProperties? = null,
 ) {
     val progress by animateLottieCompositionAsState(
         composition,
@@ -155,6 +165,7 @@
         outlineMasksAndMattes,
         applyOpacityToLayers,
         enableMergePaths,
+        dynamicProperties,
     )
 }
 
diff --git a/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieDynamicProperties.kt b/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieDynamicProperties.kt
new file mode 100644
index 0000000..653311e
--- /dev/null
+++ b/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieDynamicProperties.kt
@@ -0,0 +1,156 @@
+package com.airbnb.lottie.compose
+
+import android.graphics.ColorFilter
+import android.graphics.PointF
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import com.airbnb.lottie.LottieDrawable
+import com.airbnb.lottie.model.KeyPath
+import com.airbnb.lottie.value.LottieFrameInfo
+import com.airbnb.lottie.value.LottieValueCallback
+import com.airbnb.lottie.value.ScaleXY
+
+/**
+ * Use this function when you want to apply one or more dynamic properties to an animation.
+ * This takes a vararg of individual dynamic properties which should be created with [rememberLottieDynamicProperty].
+ *
+ * @see rememberLottieDynamicProperty
+ */
+@Composable
+fun rememberLottieDynamicProperties(
+    vararg properties: LottieDynamicProperty<*>,
+): LottieDynamicProperties {
+    return remember(properties) {
+        LottieDynamicProperties(properties.toList())
+    }
+}
+
+/**
+ * Use this to create a single dynamic property for an animation.
+ *
+ * @param property should be one of [com.airbnb.lottie.LottieProperty].
+ * @param value the desired value to use as this property's value.
+ * @param keyPath the string parts of a [com.airbnb.lottie.model.KeyPath] that specify which animation element
+ *                the property resides on.
+ */
+@Composable
+fun <T> rememberLottieDynamicProperty(
+    property: T,
+    value: T,
+    vararg keyPath: String,
+): LottieDynamicProperty<T> {
+    val keyPathObj = remember(keyPath) { KeyPath(*keyPath) }
+    return remember(keyPathObj, property, value) {
+        LottieDynamicProperty(property, keyPathObj, value)
+    }
+}
+
+/**
+ * Use this to create a single dynamic property for an animation.
+ *
+ * @param property Should be one of [com.airbnb.lottie.LottieProperty].
+ * @param keyPath The string parts of a [com.airbnb.lottie.model.KeyPath] that specify which animation element
+ *                the property resides on.
+ * @param callback A callback that will be invoked during the drawing pass with current frame info. The frame
+ *                 info can be used to alter the property's value based on the original animation data or it
+ *                 can be completely ignored and an arbitrary value can be returned. In this case, you may want
+ *                 the overloaded version of this function that takes a static value instead of a callback.
+ */
+@Composable
+fun <T> rememberLottieDynamicProperty(
+    property: T,
+    vararg keyPath: String,
+    callback: (frameInfo: LottieFrameInfo<T>) -> T,
+): LottieDynamicProperty<T> {
+    val keyPathObj = remember(keyPath) { KeyPath(*keyPath) }
+    val callbackState by rememberUpdatedState(callback)
+    return remember(keyPathObj, property) {
+        LottieDynamicProperty(
+            property,
+            keyPathObj,
+        ) { callbackState(it) }
+    }
+}
+
+/**
+ * @see rememberLottieDynamicProperty
+ */
+class LottieDynamicProperty<T> internal constructor(
+    internal val property: T,
+    internal val keyPath: KeyPath,
+    internal val callback: (frameInfo: LottieFrameInfo<T>) -> T,
+) {
+    constructor(property: T, keyPath: KeyPath, value: T) : this(property, keyPath, { value })
+}
+
+/**
+ * @see rememberLottieDynamicProperties
+ */
+class LottieDynamicProperties internal constructor(
+    private val intProperties: List<LottieDynamicProperty<Int>>,
+    private val pointFProperties: List<LottieDynamicProperty<PointF>>,
+    private val floatProperties: List<LottieDynamicProperty<Float>>,
+    private val scaleProperties: List<LottieDynamicProperty<ScaleXY>>,
+    private val colorFilterProperties: List<LottieDynamicProperty<ColorFilter>>,
+    private val intArrayProperties: List<LottieDynamicProperty<IntArray>>,
+) {
+    @Suppress("UNCHECKED_CAST")
+    constructor(properties: List<LottieDynamicProperty<*>>) : this(
+        properties.filter { it.property is Int } as List<LottieDynamicProperty<Int>>,
+        properties.filter { it.property is PointF } as List<LottieDynamicProperty<PointF>>,
+        properties.filter { it.property is Float } as List<LottieDynamicProperty<Float>>,
+        properties.filter { it.property is ScaleXY } as List<LottieDynamicProperty<ScaleXY>>,
+        properties.filter { it.property is ColorFilter } as List<LottieDynamicProperty<ColorFilter>>,
+        properties.filter { it.property is IntArray } as List<LottieDynamicProperty<IntArray>>,
+    )
+
+    internal fun addTo(drawable: LottieDrawable) {
+        intProperties.forEach { p ->
+            drawable.addValueCallback(p.keyPath, p.property, p.callback.toValueCallback())
+        }
+        pointFProperties.forEach { p ->
+            drawable.addValueCallback(p.keyPath, p.property, p.callback.toValueCallback())
+        }
+        floatProperties.forEach { p ->
+            drawable.addValueCallback(p.keyPath, p.property, p.callback.toValueCallback())
+        }
+        scaleProperties.forEach { p ->
+            drawable.addValueCallback(p.keyPath, p.property, p.callback.toValueCallback())
+        }
+        colorFilterProperties.forEach { p ->
+            drawable.addValueCallback(p.keyPath, p.property, p.callback.toValueCallback())
+        }
+        intArrayProperties.forEach { p ->
+            drawable.addValueCallback(p.keyPath, p.property, p.callback.toValueCallback())
+        }
+    }
+
+    internal fun removeFrom(drawable: LottieDrawable) {
+        intProperties.forEach { p ->
+            drawable.addValueCallback(p.keyPath, p.property, null as LottieValueCallback<Int>?)
+        }
+        pointFProperties.forEach { p ->
+            drawable.addValueCallback(p.keyPath, p.property, null as LottieValueCallback<PointF>?)
+        }
+        floatProperties.forEach { p ->
+            drawable.addValueCallback(p.keyPath, p.property, null as LottieValueCallback<Float>?)
+        }
+        scaleProperties.forEach { p ->
+            drawable.addValueCallback(p.keyPath, p.property, null as LottieValueCallback<ScaleXY>?)
+        }
+        colorFilterProperties.forEach { p ->
+            drawable.addValueCallback(p.keyPath, p.property, null as LottieValueCallback<ColorFilter>?)
+        }
+        intArrayProperties.forEach { p ->
+            drawable.addValueCallback(p.keyPath, p.property, null as LottieValueCallback<IntArray>?)
+        }
+    }
+}
+
+private fun <T> ((frameInfo: LottieFrameInfo<T>) -> T).toValueCallback() = object : LottieValueCallback<T>() {
+    override fun getValue(frameInfo: LottieFrameInfo<T>): T {
+        return invoke(frameInfo)
+    }
+}
\ No newline at end of file
diff --git a/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java b/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java
index 4ef2f0f..a0e26e1 100644
--- a/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java
+++ b/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java
@@ -1026,7 +1026,7 @@
    * {@link #resolveKeyPath(KeyPath)} and will resolve it if it hasn't.
    */
   public <T> void addValueCallback(
-      final KeyPath keyPath, final T property, final LottieValueCallback<T> callback) {
+      final KeyPath keyPath, final T property, @Nullable final LottieValueCallback<T> callback) {
     if (compositionLayer == null) {
       lazyCompositionTasks.add(new LazyCompositionTask() {
         @Override
diff --git a/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/ComposeActivity.kt b/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/ComposeActivity.kt
index a810e90..3bd72ec 100644
--- a/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/ComposeActivity.kt
+++ b/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/ComposeActivity.kt
@@ -26,6 +26,7 @@
 import com.airbnb.lottie.compose.LottieCompositionSpec
 import com.airbnb.lottie.sample.compose.examples.AnimatableExamplesPage
 import com.airbnb.lottie.sample.compose.examples.BasicUsageExamplesPage
+import com.airbnb.lottie.sample.compose.examples.DynamicPropertiesExamplesPage
 import com.airbnb.lottie.sample.compose.examples.ExamplesPage
 import com.airbnb.lottie.sample.compose.examples.NetworkExamplesPage
 import com.airbnb.lottie.sample.compose.examples.TransitionsExamplesPage
@@ -96,6 +97,7 @@
                         composable(Route.TransitionsExamples.route) { TransitionsExamplesPage() }
                         composable(Route.ViewPagerExample.route) { ViewPagerExamplePage() }
                         composable(Route.NetworkExamples.route) { NetworkExamplesPage() }
+                        composable(Route.DynamicProperties.route) { DynamicPropertiesExamplesPage() }
                         composable(
                             Route.Player.fullRoute,
                             arguments = Route.Player.args
diff --git a/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/Route.kt b/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/Route.kt
index daa5f19..2dd9b5d 100644
--- a/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/Route.kt
+++ b/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/Route.kt
@@ -26,6 +26,8 @@
 
     object NetworkExamples : Route("network examples")
 
+    object DynamicProperties : Route("dynamic properties examples")
+
     object Player : Route(
         "player",
         listOf(
diff --git a/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/examples/DynamicPropertiesExamplesPage.kt b/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/examples/DynamicPropertiesExamplesPage.kt
new file mode 100644
index 0000000..577d22e
--- /dev/null
+++ b/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/examples/DynamicPropertiesExamplesPage.kt
@@ -0,0 +1,155 @@
+package com.airbnb.lottie.sample.compose.examples
+
+import android.graphics.PointF
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.dp
+import com.airbnb.lottie.LottieProperty
+import com.airbnb.lottie.compose.LottieAnimation
+import com.airbnb.lottie.compose.LottieCompositionSpec
+import com.airbnb.lottie.compose.LottieConstants
+import com.airbnb.lottie.compose.rememberLottieComposition
+import com.airbnb.lottie.compose.rememberLottieDynamicProperties
+import com.airbnb.lottie.compose.rememberLottieDynamicProperty
+import com.airbnb.lottie.sample.compose.R
+
+@Composable
+fun DynamicPropertiesExamplesPage() {
+    UsageExamplePageScaffold {
+        Column(
+            modifier = Modifier
+                .fillMaxWidth()
+                .verticalScroll(rememberScrollState())
+        ) {
+            ExampleCard("Heart Color", "Click to change color") {
+                HeartColor()
+            }
+            ExampleCard("Jump Height", "Click to jump height") {
+                JumpHeight()
+            }
+            ExampleCard("Change Properties", "Click to toggle whether the dynamic property is used") {
+                ToggleProperty()
+            }
+        }
+    }
+}
+
+@Composable
+private fun HeartColor() {
+    val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.heart))
+    val colors = remember {
+        listOf(
+            Color.Red,
+            Color.Green,
+            Color.Blue,
+            Color.Yellow,
+        )
+    }
+    var colorIndex by remember { mutableStateOf(0) }
+    val color by derivedStateOf { colors[colorIndex] }
+
+    val dynamicProperties = rememberLottieDynamicProperties(
+        rememberLottieDynamicProperty(
+            property = LottieProperty.COLOR,
+            value = color.toArgb(),
+            keyPath = arrayOf(
+                "H2",
+                "Shape 1",
+                "Fill 1",
+            )
+        ),
+    )
+    LottieAnimation(
+        composition,
+        iterations = LottieConstants.IterateForever,
+        dynamicProperties = dynamicProperties,
+        modifier = Modifier
+            .clickable(
+                interactionSource = remember { MutableInteractionSource() },
+                indication = null,
+                onClick = { colorIndex = (colorIndex + 1) % colors.size },
+            )
+    )
+}
+
+@Composable
+private fun JumpHeight() {
+    val composition by rememberLottieComposition(LottieCompositionSpec.Asset("AndroidWave.json"))
+    val extraJumpHeights = remember { listOf(0.dp, 24.dp, 48.dp, 128.dp) }
+    var extraJumpIndex by remember { mutableStateOf(0) }
+    val extraJumpHeight by derivedStateOf { extraJumpHeights[extraJumpIndex] }
+    val extraJumpHeightPx = with(LocalDensity.current) { extraJumpHeight.toPx() }
+
+    val point = remember { PointF() }
+    val dynamicProperties = rememberLottieDynamicProperties(
+        rememberLottieDynamicProperty(LottieProperty.TRANSFORM_POSITION, "Body") { frameInfo ->
+            var startY = frameInfo.startValue.y
+            var endY = frameInfo.endValue.y
+            when {
+                startY > endY -> startY += extraJumpHeightPx
+                else -> endY += extraJumpHeightPx
+            }
+            point.set(frameInfo.startValue.x, lerp(startY, endY, frameInfo.interpolatedKeyframeProgress))
+            point
+        }
+    )
+    LottieAnimation(
+        composition,
+        iterations = LottieConstants.IterateForever,
+        dynamicProperties = dynamicProperties,
+        modifier = Modifier
+            .clickable(
+                interactionSource = remember { MutableInteractionSource() },
+                indication = null,
+                onClick = { extraJumpIndex = (extraJumpIndex + 1) % extraJumpHeights.size },
+            )
+    )
+}
+
+@Composable
+private fun ToggleProperty() {
+    val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.heart))
+    var useDynamicProperty by remember { mutableStateOf(true) }
+    val dynamicProperties = rememberLottieDynamicProperties(
+        rememberLottieDynamicProperty(
+            property = LottieProperty.COLOR,
+            value = Color.Green.toArgb(),
+            keyPath = arrayOf(
+                "H2",
+                "Shape 1",
+                "Fill 1",
+            )
+        ),
+    )
+    LottieAnimation(
+        composition,
+        iterations = LottieConstants.IterateForever,
+        dynamicProperties = dynamicProperties.takeIf { useDynamicProperty },
+        modifier = Modifier
+            .clickable(
+                interactionSource = remember { MutableInteractionSource() },
+                indication = null,
+                onClick = { useDynamicProperty = !useDynamicProperty },
+            )
+    )
+}
+
+private fun lerp(valueA: Float, valueB: Float, progress: Float): Float {
+    val smallerY = minOf(valueA, valueB)
+    val largerY = maxOf(valueA, valueB)
+    return smallerY + progress * (largerY - smallerY)
+}
\ No newline at end of file
diff --git a/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/examples/ExamplesPage.kt b/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/examples/ExamplesPage.kt
index c8eaaa0..e2097ec 100644
--- a/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/examples/ExamplesPage.kt
+++ b/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/examples/ExamplesPage.kt
@@ -52,5 +52,11 @@
             modifier = Modifier
                 .clickable { navController.navigate(Route.NetworkExamples) }
         )
+        ListItem(
+            text = { Text("Dynamic Properties") },
+            secondaryText = { Text("Setting dynamic properties") },
+            modifier = Modifier
+                .clickable { navController.navigate(Route.DynamicProperties) }
+        )
     }
 }
\ No newline at end of file