[Compose] Allow setting and remapping fonts (#1842)
This PR adds 4 APIs to load typefaces in lottie-compose:
1) Using default paths. Fonts are in /assets/fonts/FAMILY_NAME.ttf
2) Overriding the assets subfolder or font file extension
3) Remapping family names to font files in assets
4) Dynamic properties
The dynamic properties API can also be used with lottie-android.
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 653311e..ef791a9 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
@@ -2,6 +2,7 @@
import android.graphics.ColorFilter
import android.graphics.PointF
+import android.graphics.Typeface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@@ -95,6 +96,7 @@
private val scaleProperties: List<LottieDynamicProperty<ScaleXY>>,
private val colorFilterProperties: List<LottieDynamicProperty<ColorFilter>>,
private val intArrayProperties: List<LottieDynamicProperty<IntArray>>,
+ private val typefaceProperties: List<LottieDynamicProperty<Typeface>>,
) {
@Suppress("UNCHECKED_CAST")
constructor(properties: List<LottieDynamicProperty<*>>) : this(
@@ -104,6 +106,7 @@
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>>,
+ properties.filter { it.property is Typeface } as List<LottieDynamicProperty<Typeface>>,
)
internal fun addTo(drawable: LottieDrawable) {
@@ -125,6 +128,9 @@
intArrayProperties.forEach { p ->
drawable.addValueCallback(p.keyPath, p.property, p.callback.toValueCallback())
}
+ typefaceProperties.forEach { p ->
+ drawable.addValueCallback(p.keyPath, p.property, p.callback.toValueCallback())
+ }
}
internal fun removeFrom(drawable: LottieDrawable) {
@@ -146,6 +152,9 @@
intArrayProperties.forEach { p ->
drawable.addValueCallback(p.keyPath, p.property, null as LottieValueCallback<IntArray>?)
}
+ typefaceProperties.forEach { p ->
+ drawable.addValueCallback(p.keyPath, p.property, null as LottieValueCallback<Typeface>?)
+ }
}
}
diff --git a/lottie-compose/src/main/java/com/airbnb/lottie/compose/rememberLottieComposition.kt b/lottie-compose/src/main/java/com/airbnb/lottie/compose/rememberLottieComposition.kt
index b9c0d51..e3ae50b 100644
--- a/lottie-compose/src/main/java/com/airbnb/lottie/compose/rememberLottieComposition.kt
+++ b/lottie-compose/src/main/java/com/airbnb/lottie/compose/rememberLottieComposition.kt
@@ -2,6 +2,7 @@
import android.content.Context
import android.graphics.BitmapFactory
+import android.graphics.Typeface
import android.util.Base64
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -13,6 +14,7 @@
import com.airbnb.lottie.LottieCompositionFactory
import com.airbnb.lottie.LottieImageAsset
import com.airbnb.lottie.LottieTask
+import com.airbnb.lottie.model.Font
import com.airbnb.lottie.utils.Logger
import com.airbnb.lottie.utils.Utils
import kotlinx.coroutines.Dispatchers
@@ -45,6 +47,18 @@
* @param imageAssetsFolder A subfolder in `src/main/assets` that contains the exported images
* that this composition uses. DO NOT rename any images from your design tool. The
* filenames must match the values that are in your json file.
+ * @param fontAssetsFolder The default folder Lottie will look in to find font files. Fonts will be matched
+ * based on the family name specified in the Lottie json file.
+ * Defaults to "fonts/" so if "Helvetica" was in the Json file, Lottie will auto-match
+ * fonts located at "src/main/assets/fonts/Helvetica.ttf". Missing fonts will be skipped
+ * and should be set via fontRemapping or via dynamic properties.
+ * @param fontFileExtension The default file extension for font files specified in the fontAssetsFolder or fontRemapping.
+ * Defaults to ttf.
+ * @param fontRemapping Remaps family names as specified in the Lottie json file to font files stored in the fontAssetsFolder.
+ * This will automatically add the fontFileExtension so you should not include the font file extension
+ * in your remapping.
+ * @param cacheComposition Whether or not to cache the composition. If set to true, the next time an composition with this
+ * spec is fetched, it will return the existing one instead of parsing it again.
* @param onRetry An optional callback that will be called if loading the animation fails.
* It is passed the failed count (the number of times it has failed) and the exception
* from the previous attempt to load the composition. [onRetry] is a suspending function
@@ -55,6 +69,9 @@
fun rememberLottieComposition(
spec: LottieCompositionSpec,
imageAssetsFolder: String? = null,
+ fontAssetsFolder: String = "fonts/",
+ fontFileExtension: String = ".ttf",
+ fontRemapping: Map<String, String> = emptyMap(),
cacheComposition: Boolean = true,
onRetry: suspend (failCount: Int, previousException: Throwable) -> Boolean = { _, _ -> false },
): LottieCompositionResult {
@@ -69,6 +86,9 @@
context,
spec,
imageAssetsFolder.ensureTrailingSlash(),
+ fontAssetsFolder.ensureTrailingSlash(),
+ fontFileExtension.ensureLeadingPeriod(),
+ fontRemapping,
cacheComposition,
)
result.complete(composition)
@@ -88,6 +108,9 @@
context: Context,
spec: LottieCompositionSpec,
imageAssetsFolder: String?,
+ fontAssetsFolder: String?,
+ fontFileExtension: String,
+ fontRemapping: Map<String, String>,
cacheComposition: Boolean,
): LottieComposition {
val task = when (spec) {
@@ -111,7 +134,10 @@
FileInputStream(spec.fileName)
}
when {
- spec.fileName.endsWith("zip") -> LottieCompositionFactory.fromZipStream(ZipInputStream(fis), spec.fileName.takeIf { cacheComposition })
+ spec.fileName.endsWith("zip") -> LottieCompositionFactory.fromZipStream(
+ ZipInputStream(fis),
+ spec.fileName.takeIf { cacheComposition },
+ )
else -> LottieCompositionFactory.fromJsonInputStream(fis, spec.fileName.takeIf { cacheComposition })
}
}
@@ -129,6 +155,7 @@
val composition = task.await()
loadImagesFromAssets(context, composition, imageAssetsFolder)
+ loadFontsFromAssets(context, composition, fontAssetsFolder, fontFileExtension, fontRemapping)
return composition
}
@@ -198,8 +225,63 @@
}
}
+private suspend fun loadFontsFromAssets(
+ context: Context,
+ composition: LottieComposition,
+ fontAssetsFolder: String?,
+ fontFileExtension: String,
+ fontRemapping: Map<String, String>,
+) {
+ if (composition.fonts.isEmpty()) return
+ withContext(Dispatchers.IO) {
+ for (font in composition.fonts.values) {
+ maybeLoadTypefaceFromAssets(context, font, fontAssetsFolder, fontFileExtension, fontRemapping[font.family])
+ }
+ }
+}
+
+private fun maybeLoadTypefaceFromAssets(
+ context: Context,
+ font: Font,
+ fontAssetsFolder: String?,
+ fontFileExtension: String,
+ remappedFontPath: String?,
+) {
+ val path = remappedFontPath ?: "$fontAssetsFolder${font.family}${fontFileExtension}"
+ val typefaceWithDefaultStyle = try {
+ Typeface.createFromAsset(context.assets, path)
+ } catch (e: Exception) {
+ Logger.error("Failed to find typeface in assets with path $path.", e)
+ return
+ }
+ try {
+ val typefaceWithStyle = typefaceForStyle(typefaceWithDefaultStyle, font.style)
+ font.typeface = typefaceWithStyle
+ } catch (e: Exception) {
+ Logger.error("Failed to create ${font.family} typeface with style=${font.style}!", e)
+ }
+}
+
+private fun typefaceForStyle(typeface: Typeface, style: String): Typeface? {
+ val containsItalic = style.contains("Italic")
+ val containsBold = style.contains("Bold")
+ val styleInt = when {
+ containsItalic && containsBold -> Typeface.BOLD_ITALIC
+ containsItalic -> Typeface.ITALIC
+ containsBold -> Typeface.BOLD
+ else -> Typeface.NORMAL
+ }
+ return if (typeface.style == styleInt) typeface else Typeface.create(typeface, styleInt)
+}
+
private fun String?.ensureTrailingSlash(): String? = when {
- this == null -> null
+ isNullOrBlank() -> null
endsWith('/') -> this
else -> "$this/"
+}
+
+private fun String.ensureLeadingPeriod(): String = when {
+ isBlank() -> this
+ startsWith(".") -> this
+ else -> ".$this"
}
\ No newline at end of file
diff --git a/lottie/src/main/java/com/airbnb/lottie/LottieProperty.java b/lottie/src/main/java/com/airbnb/lottie/LottieProperty.java
index 98dea45..0686156 100644
--- a/lottie/src/main/java/com/airbnb/lottie/LottieProperty.java
+++ b/lottie/src/main/java/com/airbnb/lottie/LottieProperty.java
@@ -2,6 +2,7 @@
import android.graphics.ColorFilter;
import android.graphics.PointF;
+import android.graphics.Typeface;
import com.airbnb.lottie.value.LottieValueCallback;
import com.airbnb.lottie.value.ScaleXY;
@@ -164,4 +165,6 @@
ColorFilter COLOR_FILTER = new ColorFilter();
Integer[] GRADIENT_COLOR = new Integer[0];
+
+ Typeface TYPEFACE = Typeface.DEFAULT;
}
diff --git a/lottie/src/main/java/com/airbnb/lottie/model/Font.java b/lottie/src/main/java/com/airbnb/lottie/model/Font.java
index 9c74ff0..5aa3d45 100644
--- a/lottie/src/main/java/com/airbnb/lottie/model/Font.java
+++ b/lottie/src/main/java/com/airbnb/lottie/model/Font.java
@@ -2,6 +2,9 @@
import static androidx.annotation.RestrictTo.Scope.LIBRARY;
+import android.graphics.Typeface;
+
+import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
@RestrictTo(LIBRARY)
@@ -12,6 +15,9 @@
private final String style;
private final float ascent;
+ @Nullable
+ private Typeface typeface;
+
public Font(String family, String name, String style, float ascent) {
this.family = family;
this.name = name;
@@ -34,4 +40,13 @@
@SuppressWarnings("unused") float getAscent() {
return ascent;
}
+
+ @Nullable
+ public Typeface getTypeface() {
+ return typeface;
+ }
+
+ public void setTypeface(@Nullable Typeface typeface) {
+ this.typeface = typeface;
+ }
}
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 d15c537..b0f3b2d 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
@@ -47,7 +47,7 @@
setStyle(Style.STROKE);
}};
private final Map<FontCharacter, List<ContentGroup>> contentsForCharacter = new HashMap<>();
- private final LongSparseArray<String> codePointCache = new LongSparseArray<String>();
+ private final LongSparseArray<String> codePointCache = new LongSparseArray<>();
private final TextKeyframeAnimation textAnimation;
private final LottieDrawable lottieDrawable;
private final LottieComposition composition;
@@ -71,6 +71,8 @@
private BaseKeyframeAnimation<Float, Float> textSizeAnimation;
@Nullable
private BaseKeyframeAnimation<Float, Float> textSizeCallbackAnimation;
+ @Nullable
+ private BaseKeyframeAnimation<Typeface, Typeface> typefaceCallbackAnimation;
TextLayer(LottieDrawable lottieDrawable, Layer layerModel) {
super(lottieDrawable, layerModel);
@@ -236,8 +238,7 @@
private void drawTextWithFont(
DocumentData documentData, Font font, Matrix parentMatrix, Canvas canvas) {
- float parentScale = Utils.getScale(parentMatrix);
- Typeface typeface = lottieDrawable.getTypeface(font.getFamily(), font.getStyle());
+ Typeface typeface = getTypeface(font);
if (typeface == null) {
return;
}
@@ -298,6 +299,21 @@
}
}
+ @Nullable
+ private Typeface getTypeface(Font font) {
+ if (typefaceCallbackAnimation != null) {
+ Typeface callbackTypeface = typefaceCallbackAnimation.getValue();
+ if (callbackTypeface != null) {
+ return callbackTypeface;
+ }
+ }
+ Typeface drawableTypeface = lottieDrawable.getTypeface(font.getFamily(), font.getStyle());
+ if (drawableTypeface != null) {
+ return drawableTypeface;
+ }
+ return font.getTypeface();
+ }
+
private List<String> getTextLines(String text) {
// Split full text by carriage return character
String formattedText = text.replaceAll("\r\n", "\r")
@@ -517,6 +533,18 @@
textSizeCallbackAnimation.addUpdateListener(this);
addAnimation(textSizeCallbackAnimation);
}
+ } else if (property == LottieProperty.TYPEFACE) {
+ if (typefaceCallbackAnimation != null) {
+ removeAnimation(typefaceCallbackAnimation);
+ }
+
+ if (callback == null) {
+ typefaceCallbackAnimation = null;
+ } else {
+ typefaceCallbackAnimation = new ValueCallbackKeyframeAnimation<>((LottieValueCallback<Typeface>) callback);
+ typefaceCallbackAnimation.addUpdateListener(this);
+ addAnimation(typefaceCallbackAnimation);
+ }
}
}
}
diff --git a/sample-compose/src/main/assets/fonts/Comic Neue.ttf b/sample-compose/src/main/assets/fonts/Comic Neue.ttf
new file mode 100755
index 0000000..ab3a417
--- /dev/null
+++ b/sample-compose/src/main/assets/fonts/Comic Neue.ttf
Binary files differ
diff --git a/sample-compose/src/main/assets/fonts/Roboto.ttf b/sample-compose/src/main/assets/fonts/Roboto.ttf
new file mode 100755
index 0000000..2c97eea
--- /dev/null
+++ b/sample-compose/src/main/assets/fonts/Roboto.ttf
Binary files differ
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 9b12b5f..af9dd05 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
@@ -30,6 +30,7 @@
import com.airbnb.lottie.sample.compose.examples.ExamplesPage
import com.airbnb.lottie.sample.compose.examples.ImagesExamplesPage
import com.airbnb.lottie.sample.compose.examples.NetworkExamplesPage
+import com.airbnb.lottie.sample.compose.examples.TextExamplesPage
import com.airbnb.lottie.sample.compose.examples.TransitionsExamplesPage
import com.airbnb.lottie.sample.compose.examples.ViewPagerExamplePage
import com.airbnb.lottie.sample.compose.lottiefiles.LottieFilesPage
@@ -100,6 +101,7 @@
composable(Route.NetworkExamples.route) { NetworkExamplesPage() }
composable(Route.DynamicPropertiesExamples.route) { DynamicPropertiesExamplesPage() }
composable(Route.ImagesExamples.route) { ImagesExamplesPage() }
+ composable(Route.TextExamples.route) { TextExamplesPage() }
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 719d219..9c8ee0e 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
@@ -28,6 +28,8 @@
object ImagesExamples : Route("image examples")
+ object TextExamples : Route("text examples")
+
object DynamicPropertiesExamples : Route("dynamic properties examples")
object Player : Route(
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 bb85651..b4c4402 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
@@ -64,5 +64,11 @@
modifier = Modifier
.clickable { navController.navigate(Route.ImagesExamples) }
)
+ ListItem(
+ text = { Text("Text") },
+ secondaryText = { Text("Using animations with text") },
+ modifier = Modifier
+ .clickable { navController.navigate(Route.TextExamples) }
+ )
}
}
\ No newline at end of file
diff --git a/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/examples/TextExamplesPage.kt b/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/examples/TextExamplesPage.kt
new file mode 100644
index 0000000..52a0140
--- /dev/null
+++ b/sample-compose/src/main/java/com/airbnb/lottie/sample/compose/examples/TextExamplesPage.kt
@@ -0,0 +1,102 @@
+package com.airbnb.lottie.sample.compose.examples
+
+import android.graphics.Typeface
+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.LaunchedEffect
+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.platform.LocalContext
+import com.airbnb.lottie.LottieProperty
+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.sample.compose.R
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+@Composable
+fun TextExamplesPage() {
+ UsageExamplePageScaffold {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ ) {
+ ExampleCard("Default", "Loading fonts using default asset paths") {
+ Example1()
+ }
+ ExampleCard("Font Remapping", "Replace fonts using font remapping") {
+ Example2()
+ }
+ ExampleCard("Dynamic Properties", "Replace fonts with custom typefaces") {
+ Example3()
+ }
+ }
+ }
+}
+
+@Composable
+private fun Example1() {
+ // Lottie will automatically look for fonts in src/main/assets/fonts.
+ // It will find font files based on the font family specified in the Lottie Json file.
+ // You can specify a different assets subfolder by using the fontAssetsFolder parameter.
+ // By default, it will look for ttf files.
+ // You can specify a different file extension by using the fontFileExtension parameter.
+ val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.name))
+ LottieAnimation(
+ composition,
+ progress = 0f,
+ )
+}
+
+@Composable
+private fun Example2() {
+ val composition by rememberLottieComposition(
+ LottieCompositionSpec.RawRes(R.raw.name),
+ // Comic Neue is the font family set in the Lottie json file.
+ fontRemapping = mapOf("Comic Neue" to "fonts/Roboto.ttf"),
+ // Don't cache the composition because it has a custom font remapping.
+ cacheComposition = false,
+ )
+ LottieAnimation(
+ composition,
+ progress = 0f,
+ )
+}
+
+@Composable
+private fun Example3() {
+ val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.name))
+ val typeface = rememberTypeface("fonts/Roboto.ttf")
+ val dynamicProperties = rememberLottieDynamicProperties(
+ rememberLottieDynamicProperty(LottieProperty.TYPEFACE, typeface, "NAME")
+ )
+
+ LottieAnimation(
+ composition,
+ progress = 0f,
+ dynamicProperties = dynamicProperties,
+ )
+}
+
+@Composable
+private fun rememberTypeface(path: String): Typeface? {
+ var typeface: Typeface? by remember { mutableStateOf(null) }
+ val context = LocalContext.current
+ LaunchedEffect(path) {
+ typeface = null
+ withContext(Dispatchers.IO) {
+ typeface = Typeface.createFromAsset(context.assets, "fonts/Roboto.ttf")
+ }
+ }
+ return typeface
+}
\ No newline at end of file
diff --git a/sample-compose/src/main/res/raw/name.json b/sample-compose/src/main/res/raw/name.json
new file mode 100644
index 0000000..fad053c
--- /dev/null
+++ b/sample-compose/src/main/res/raw/name.json
@@ -0,0 +1,2 @@
+{"v":"4.8.0","fr":29.9700012207031,"ip":0,"op":61.0000024845809,"w":150,"h":150,"nm":"Name",
+ "ddd":0,"assets":[],"fonts":{"list":[{"origin":0,"fPath":"","fClass":"","fFamily":"Comic Neue","fWeight":"","fStyle":"Regular","fName":"ComicNeue","ascent":69.6990966796875}]},"layers":[{"ddd":0,"ind":1,"ty":5,"nm":"NAME","ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"n":"0p833_0p833_0p167_0p167","t":0,"s":[10.5,15,0],"e":[10.5,147,0],"to":[0,22,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"n":"0p833_0p833_0p167_0p167","t":15,"s":[10.5,147,0],"e":[10.5,15,0],"to":[0,0,0],"ti":[0,22,0]},{"t":30.0000012219251}]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"n":["0p833_0p833_0p167_0p167","0p833_0p833_0p167_0p167","0p833_0p833_0p167_0p167"],"t":0,"s":[100,100,100],"e":[196,196,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"n":["0p833_0p833_0p167_0p167","0p833_0p833_0p167_0p167","0p833_0p833_0p167_0p167"],"t":15,"s":[196,196,100],"e":[100,100,100]},{"t":30.0000012219251}]}},"ao":0,"t":{"d":{"k":[{"s":{"s":14,"f":"ComicNeue","t":"NAME","j":0,"tr":0,"lh":16.8,"ls":0,"fc":[0.92,0,0]},"t":0}]},"p":{},"m":{"g":1,"a":{"a":0,"k":[0,0]}},"a":[{"s":{"t":0,"xe":{"a":0,"k":0},"ne":{"a":0,"k":0},"a":{"a":0,"k":100},"b":1,"rn":0,"sh":1,"r":1},"a":{"fc":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":0,"s":[1,0,0,1],"e":[0,1,0,1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":15,"s":[0,1,0,1],"e":[0,0,1,1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":30,"s":[0,0,1,1],"e":[0,1,0,1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":45,"s":[0,1,0,1],"e":[1,0,0,1]},{"t":60.0000024438501}]},"t":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":30,"s":[0],"e":[20]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":45,"s":[20],"e":[0]},{"t":60.0000024438501}]}}}]},"ip":0,"op":61.0000024845809,"st":0,"bm":0,"sr":1}]}
\ No newline at end of file