Add support for text in dynamic properties (#1995)

The original TextDelegate API pre-dates dynamic properties. Compose only has access to the newer dynamic properties API so this PR extends dynamic property support to include text.

Fixes #1903
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index 38a560b..cfaf1ff 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -5,6 +5,9 @@
       <option name="ANNOTATION_PARAMETER_WRAP" value="1" />
     </JavaCodeStyleSettings>
     <JetCodeStyleSettings>
+      <option name="PACKAGES_TO_USE_STAR_IMPORTS">
+        <value />
+      </option>
       <option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
       <option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
     </JetCodeStyleSettings>
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
index 039bde0..cccd6bb 100644
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -1,6 +1,7 @@
 <component name="InspectionProjectProfileManager">
   <profile version="1.0">
     <option name="myName" value="Project Default" />
+    <inspection_tool class="EqualsReplaceableByObjectsCall" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
     <inspection_tool class="ForCanBeForeach" enabled="false" level="WARNING" enabled_by_default="false">
       <option name="REPORT_INDEXED_LOOP" value="false" />
       <option name="ignoreUntypedCollections" value="false" />
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
index ea1331c..5edd399 100644
--- a/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieDynamicProperties.kt
+++ b/lottie-compose/src/main/java/com/airbnb/lottie/compose/LottieDynamicProperties.kt
@@ -25,7 +25,7 @@
 fun rememberLottieDynamicProperties(
     vararg properties: LottieDynamicProperty<*>,
 ): LottieDynamicProperties {
-    return remember(properties) {
+    return remember(properties.contentHashCode()) {
         LottieDynamicProperties(properties.toList())
     }
 }
@@ -67,7 +67,7 @@
     vararg keyPath: String,
     callback: (frameInfo: LottieFrameInfo<T>) -> T,
 ): LottieDynamicProperty<T> {
-    val keyPathObj = remember(keyPath) { KeyPath(*keyPath) }
+    val keyPathObj = remember(keyPath.contentHashCode()) { KeyPath(*keyPath) }
     val callbackState by rememberUpdatedState(callback)
     return remember(keyPathObj, property) {
         LottieDynamicProperty(
@@ -101,6 +101,7 @@
     private val intArrayProperties: List<LottieDynamicProperty<Array<*>>>,
     private val typefaceProperties: List<LottieDynamicProperty<Typeface>>,
     private val bitmapProperties: List<LottieDynamicProperty<Bitmap>>,
+    private val charSequenceProperties: List<LottieDynamicProperty<CharSequence>>,
 ) {
     @Suppress("UNCHECKED_CAST")
     constructor(properties: List<LottieDynamicProperty<*>>) : this(
@@ -112,6 +113,7 @@
         properties.filter { it.property is Array<*> } as List<LottieDynamicProperty<Array<*>>>,
         properties.filter { it.property is Typeface } as List<LottieDynamicProperty<Typeface>>,
         properties.filter { it.property is Bitmap } as List<LottieDynamicProperty<Bitmap>>,
+        properties.filter { it.property is CharSequence } as List<LottieDynamicProperty<CharSequence>>,
     )
 
     internal fun addTo(drawable: LottieDrawable) {
@@ -139,7 +141,9 @@
         bitmapProperties.forEach { p ->
             drawable.addValueCallback(p.keyPath, p.property, p.callback.toValueCallback())
         }
-
+        charSequenceProperties.forEach { p ->
+            drawable.addValueCallback(p.keyPath, p.property, p.callback.toValueCallback())
+        }
     }
 
     internal fun removeFrom(drawable: LottieDrawable) {
@@ -167,6 +171,9 @@
         bitmapProperties.forEach { p ->
             drawable.addValueCallback(p.keyPath, p.property, null as LottieValueCallback<Bitmap>?)
         }
+        charSequenceProperties.forEach { p ->
+            drawable.addValueCallback(p.keyPath, p.property, null as LottieValueCallback<CharSequence>?)
+        }
     }
 }
 
diff --git a/lottie/src/main/java/com/airbnb/lottie/LottieProperty.java b/lottie/src/main/java/com/airbnb/lottie/LottieProperty.java
index a5659fb..6ab6257 100644
--- a/lottie/src/main/java/com/airbnb/lottie/LottieProperty.java
+++ b/lottie/src/main/java/com/airbnb/lottie/LottieProperty.java
@@ -199,7 +199,9 @@
    * Keypath after the layer name.
    */
   Float DROP_SHADOW_RADIUS = 18f;
-
+  /**
+   * Set the color filter for an entire drawable content. Can be applied to fills, strokes, images, and solids.
+   */
   ColorFilter COLOR_FILTER = new ColorFilter();
   /**
    * Array of ARGB colors that map to position stops in the original gradient.
@@ -214,4 +216,8 @@
    * Set on image layers.
    */
   Bitmap IMAGE = Bitmap.createBitmap(1, 1, Bitmap.Config.ALPHA_8);
+  /**
+   * Replace the text for a text layer.
+   */
+  CharSequence TEXT = "dynamic_text";
 }
diff --git a/lottie/src/main/java/com/airbnb/lottie/animation/keyframe/TextKeyframeAnimation.java b/lottie/src/main/java/com/airbnb/lottie/animation/keyframe/TextKeyframeAnimation.java
index d2bcc6a..4ce09ce 100644
--- a/lottie/src/main/java/com/airbnb/lottie/animation/keyframe/TextKeyframeAnimation.java
+++ b/lottie/src/main/java/com/airbnb/lottie/animation/keyframe/TextKeyframeAnimation.java
@@ -1,7 +1,11 @@
 package com.airbnb.lottie.animation.keyframe;
 
+import androidx.annotation.Nullable;
+
 import com.airbnb.lottie.model.DocumentData;
 import com.airbnb.lottie.value.Keyframe;
+import com.airbnb.lottie.value.LottieFrameInfo;
+import com.airbnb.lottie.value.LottieValueCallback;
 
 import java.util.List;
 
@@ -11,10 +15,33 @@
   }
 
   @Override DocumentData getValue(Keyframe<DocumentData> keyframe, float keyframeProgress) {
-    if (keyframeProgress != 1.0f || keyframe.endValue == null) {
+    if (valueCallback != null) {
+      return valueCallback.getValueInternal(keyframe.startFrame, keyframe.endFrame == null ? Float.MAX_VALUE : keyframe.endFrame,
+          keyframe.startValue, keyframe.endValue == null ? keyframe.startValue : keyframe.endValue, keyframeProgress,
+          getInterpolatedCurrentKeyframeProgress(), getProgress());
+    } else if (keyframeProgress != 1.0f || keyframe.endValue == null) {
       return keyframe.startValue;
     } else {
       return keyframe.endValue;
     }
   }
+
+  public void setStringValueCallback(LottieValueCallback<String> valueCallback) {
+    final LottieFrameInfo<String> stringFrameInfo = new LottieFrameInfo<>();
+    final DocumentData documentData = new DocumentData();
+    super.setValueCallback(new LottieValueCallback<DocumentData>() {
+      @Override
+      public DocumentData getValue(LottieFrameInfo<DocumentData> frameInfo) {
+        stringFrameInfo.set(frameInfo.getStartFrame(), frameInfo.getEndFrame(), frameInfo.getStartValue().text,
+            frameInfo.getEndValue().text, frameInfo.getLinearKeyframeProgress(), frameInfo.getInterpolatedKeyframeProgress(),
+            frameInfo.getOverallProgress());
+        String text = valueCallback.getValue(stringFrameInfo);
+        DocumentData baseDocumentData = frameInfo.getInterpolatedKeyframeProgress() == 1f ? frameInfo.getEndValue() : frameInfo.getStartValue();
+        documentData.set(text, baseDocumentData.fontName, baseDocumentData.size, baseDocumentData.justification, baseDocumentData.tracking,
+            baseDocumentData.lineHeight, baseDocumentData.baselineShift, baseDocumentData.color, baseDocumentData.strokeColor,
+            baseDocumentData.strokeWidth, baseDocumentData.strokeOverFill);
+        return documentData;
+      }
+    });
+  }
 }
diff --git a/lottie/src/main/java/com/airbnb/lottie/model/DocumentData.java b/lottie/src/main/java/com/airbnb/lottie/model/DocumentData.java
index 87122b4..e948d07 100644
--- a/lottie/src/main/java/com/airbnb/lottie/model/DocumentData.java
+++ b/lottie/src/main/java/com/airbnb/lottie/model/DocumentData.java
@@ -14,22 +14,31 @@
     CENTER
   }
 
-  public final String text;
-  @SuppressWarnings("WeakerAccess") public final String fontName;
-  public final float size;
-  @SuppressWarnings("WeakerAccess") public final Justification justification;
-  public final int tracking;
-  @SuppressWarnings("WeakerAccess") public final float lineHeight;
-  public final float baselineShift;
-  @ColorInt public final int color;
-  @ColorInt public final int strokeColor;
-  public final float strokeWidth;
-  public final boolean strokeOverFill;
+  public String text;
+  @SuppressWarnings("WeakerAccess") public String fontName;
+  public float size;
+  @SuppressWarnings("WeakerAccess") public Justification justification;
+  public int tracking;
+  @SuppressWarnings("WeakerAccess") public float lineHeight;
+  public float baselineShift;
+  @ColorInt public int color;
+  @ColorInt public int strokeColor;
+  public float strokeWidth;
+  public boolean strokeOverFill;
 
 
   public DocumentData(String text, String fontName, float size, Justification justification, int tracking,
       float lineHeight, float baselineShift, @ColorInt int color, @ColorInt int strokeColor,
       float strokeWidth, boolean strokeOverFill) {
+    set(text, fontName, size, justification, tracking, lineHeight, baselineShift, color, strokeColor, strokeWidth, strokeOverFill);
+  }
+
+  public DocumentData() {
+  }
+
+  public void set(String text, String fontName, float size, Justification justification, int tracking,
+      float lineHeight, float baselineShift, @ColorInt int color, @ColorInt int strokeColor,
+      float strokeWidth, boolean strokeOverFill) {
     this.text = text;
     this.fontName = fontName;
     this.size = size;
diff --git a/lottie/src/main/java/com/airbnb/lottie/model/KeyPath.java b/lottie/src/main/java/com/airbnb/lottie/model/KeyPath.java
index 6d9cad1..8c110fa 100644
--- a/lottie/src/main/java/com/airbnb/lottie/model/KeyPath.java
+++ b/lottie/src/main/java/com/airbnb/lottie/model/KeyPath.java
@@ -215,6 +215,28 @@
     return keys.toString();
   }
 
+  @Override public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+
+    KeyPath keyPath = (KeyPath) o;
+
+    if (!keys.equals(keyPath.keys)) {
+      return false;
+    }
+    return resolvedElement != null ? resolvedElement.equals(keyPath.resolvedElement) : keyPath.resolvedElement == null;
+  }
+
+  @Override public int hashCode() {
+    int result = keys.hashCode();
+    result = 31 * result + (resolvedElement != null ? resolvedElement.hashCode() : 0);
+    return result;
+  }
+
   @Override public String toString() {
     return "KeyPath{" + "keys=" + keys + ",resolved=" + (resolvedElement != null) + '}';
   }
diff --git a/lottie/src/main/java/com/airbnb/lottie/model/layer/TextLayer.java b/lottie/src/main/java/com/airbnb/lottie/model/layer/TextLayer.java
index 40d41b3..3701d03 100644
--- a/lottie/src/main/java/com/airbnb/lottie/model/layer/TextLayer.java
+++ b/lottie/src/main/java/com/airbnb/lottie/model/layer/TextLayer.java
@@ -538,6 +538,8 @@
         typefaceCallbackAnimation.addUpdateListener(this);
         addAnimation(typefaceCallbackAnimation);
       }
+    } else if (property == LottieProperty.TEXT) {
+      textAnimation.setStringValueCallback((LottieValueCallback<String>) callback);
     }
   }
 }
diff --git a/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/tests/TextTestCase.kt b/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/tests/TextTestCase.kt
index f793a4e..fe48731 100644
--- a/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/tests/TextTestCase.kt
+++ b/snapshot-tests/src/androidTest/java/com/airbnb/lottie/snapshots/tests/TextTestCase.kt
@@ -1,8 +1,19 @@
 package com.airbnb.lottie.snapshots.tests
 
+import androidx.compose.runtime.getValue
+import com.airbnb.lottie.LottieCompositionFactory
+import com.airbnb.lottie.LottieProperty
 import com.airbnb.lottie.TextDelegate
+import com.airbnb.lottie.compose.LottieAnimation
+import com.airbnb.lottie.compose.LottieCompositionSpec
+import com.airbnb.lottie.compose.rememberLottieComposition
+import com.airbnb.lottie.compose.rememberLottieDynamicProperties
+import com.airbnb.lottie.compose.rememberLottieDynamicProperty
+import com.airbnb.lottie.snapshots.LocalSnapshotReady
 import com.airbnb.lottie.snapshots.SnapshotTestCase
 import com.airbnb.lottie.snapshots.SnapshotTestCaseContext
+import com.airbnb.lottie.snapshots.loadCompositionFromAssetsSync
+import com.airbnb.lottie.snapshots.snapshotComposable
 import com.airbnb.lottie.snapshots.withAnimationView
 
 class TextTestCase : SnapshotTestCase {
@@ -126,5 +137,47 @@
             animationView.setTextDelegate(textDelegate)
             textDelegate.setText("NAME", "\uD83D\uDC91")
         }
+
+        snapshotComposable("Compose Dynamic Text", "Emoji") {
+            val composition by rememberLottieComposition(LottieCompositionSpec.Asset("Tests/DynamicText.json"))
+            LocalSnapshotReady.current.value = composition != null
+            val dynamicProperties = rememberLottieDynamicProperties(
+                rememberLottieDynamicProperty(LottieProperty.TEXT, "NAME") {
+                    "🔥💪💯"
+                },
+            )
+            LottieAnimation(composition, 0f, dynamicProperties = dynamicProperties)
+        }
+
+        snapshotComposable("Compose Dynamic Text", "Taiwanese") {
+            val composition by rememberLottieComposition(LottieCompositionSpec.Asset("Tests/DynamicText.json"))
+            LocalSnapshotReady.current.value = composition != null
+            val dynamicProperties = rememberLottieDynamicProperties(
+                rememberLottieDynamicProperty(LottieProperty.TEXT, "我的密碼", "NAME"),
+            )
+            LottieAnimation(composition, 0f, dynamicProperties = dynamicProperties)
+        }
+
+        snapshotComposable("Compose Dynamic Text", "FrameInfo.startValue") {
+            val composition by rememberLottieComposition(LottieCompositionSpec.Asset("Tests/DynamicText.json"))
+            LocalSnapshotReady.current.value = composition != null
+            val dynamicProperties = rememberLottieDynamicProperties(
+                rememberLottieDynamicProperty(LottieProperty.TEXT, "NAME") { frameInfo ->
+                    "${frameInfo.startValue}!!!"
+                },
+            )
+            LottieAnimation(composition, 0f, dynamicProperties = dynamicProperties)
+        }
+
+        snapshotComposable("Compose Dynamic Text", "FrameInfo.endValue") {
+            val composition by rememberLottieComposition(LottieCompositionSpec.Asset("Tests/DynamicText.json"))
+            LocalSnapshotReady.current.value = composition != null
+            val dynamicProperties = rememberLottieDynamicProperties(
+                rememberLottieDynamicProperty(LottieProperty.TEXT, "NAME") { frameInfo ->
+                    "${frameInfo.endValue}!!!"
+                },
+            )
+            LottieAnimation(composition, 0f, dynamicProperties = dynamicProperties)
+        }
     }
 }
\ No newline at end of file