Added support for emoji in TextDelegates (#1150)
diff --git a/LottieSample/src/androidTest/java/com/airbnb/lottie/LottieTest.kt b/LottieSample/src/androidTest/java/com/airbnb/lottie/LottieTest.kt
index d4775ba..842b6e3 100644
--- a/LottieSample/src/androidTest/java/com/airbnb/lottie/LottieTest.kt
+++ b/LottieSample/src/androidTest/java/com/airbnb/lottie/LottieTest.kt
@@ -104,6 +104,7 @@
testDynamicProperties()
testMarkers()
snapshotAssets()
+ testText()
snapshotProdAnimations()
snapshotter.finalizeReportAndUpload()
}
@@ -171,6 +172,7 @@
bitmapPool.release(bitmap)
}
+ @ObsoleteCoroutinesApi
private suspend fun snapshotAssets() = coroutineScope {
val assetsChannel = listAssets()
val compositionsChannel = parseCompositionsFromAssets(assetsChannel)
@@ -587,6 +589,116 @@
}
}
+ private suspend fun testText() {
+ withAnimationView("Tests/DynamicText.json", "Dynamic Text", "Hello World") { animationView ->
+ val textDelegate = TextDelegate(animationView)
+ animationView.setTextDelegate(textDelegate)
+ textDelegate.setText("NAME", "Hello World")
+ }
+
+ withAnimationView("Tests/DynamicText.json", "Dynamic Text", "Emoji") { animationView ->
+ val textDelegate = TextDelegate(animationView)
+ animationView.setTextDelegate(textDelegate)
+ textDelegate.setText("NAME", "🔥💪💯")
+ }
+
+ withAnimationView("Tests/DynamicText.json", "Dynamic Text", "Taiwanese") { animationView ->
+ val textDelegate = TextDelegate(animationView)
+ animationView.setTextDelegate(textDelegate)
+ textDelegate.setText("NAME", "我的密碼")
+ }
+
+ withAnimationView("Tests/DynamicText.json", "Dynamic Text", "Fire Taiwanese") { animationView ->
+ val textDelegate = TextDelegate(animationView)
+ animationView.setTextDelegate(textDelegate)
+ textDelegate.setText("NAME", "🔥的A")
+ }
+
+ withAnimationView("Tests/DynamicText.json", "Dynamic Text", "Family man man girl boy") { animationView ->
+ val textDelegate = TextDelegate(animationView)
+ animationView.setTextDelegate(textDelegate)
+ textDelegate.setText("NAME", "\uD83D\uDC68\u200D\uD83D\uDC68\u200D\uD83D\uDC67\u200D\uD83D\uDC66")
+ }
+
+ withAnimationView("Tests/DynamicText.json", "Dynamic Text", "Family woman woman girl girl") { animationView ->
+ val textDelegate = TextDelegate(animationView)
+ animationView.setTextDelegate(textDelegate)
+ textDelegate.setText("NAME", "\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC67")
+ }
+
+ withAnimationView("Tests/DynamicText.json", "Dynamic Text", "Brown Police Man") { animationView ->
+ val textDelegate = TextDelegate(animationView)
+ animationView.setTextDelegate(textDelegate)
+ textDelegate.setText("NAME", "\uD83D\uDC6E\uD83C\uDFFF\u200D♀️")
+ }
+
+ withAnimationView("Tests/DynamicText.json", "Dynamic Text", "Family and Brown Police Man") { animationView ->
+ val textDelegate = TextDelegate(animationView)
+ animationView.setTextDelegate(textDelegate)
+ textDelegate.setText("NAME", "\uD83D\uDC68\u200D\uD83D\uDC68\u200D\uD83D\uDC67\u200D\uD83D\uDC67\uD83D\uDC6E\uD83C\uDFFF\u200D♀️")
+ }
+
+ withAnimationView("Tests/DynamicText.json", "Dynamic Text", "Family, Brown Police Man, emoji and chars") { animationView ->
+ val textDelegate = TextDelegate(animationView)
+ animationView.setTextDelegate(textDelegate)
+ textDelegate.setText("NAME", "🔥\uD83D\uDC68\u200D\uD83D\uDC68\u200D\uD83D\uDC67\u200D\uD83D\uDC67\uD83D\uDC6E\uD83C\uDFFF\u200D♀的Aabc️")
+ }
+
+ withAnimationView("Tests/DynamicText.json", "Dynamic Text", "Fire English Fire Brown Police Man Fire") { animationView ->
+ val textDelegate = TextDelegate(animationView)
+ animationView.setTextDelegate(textDelegate)
+ textDelegate.setText("NAME", "🔥c️🔥\uD83D\uDC6E\uD83C\uDFFF\u200D♀️\uD83D\uDD25")
+ }
+
+ withAnimationView("Tests/DynamicText.json", "Dynamic Text", "American Flag") { animationView ->
+ val textDelegate = TextDelegate(animationView)
+ animationView.setTextDelegate(textDelegate)
+ textDelegate.setText("NAME", "\uD83C\uDDFA\uD83C\uDDF8")
+ }
+
+ withAnimationView("Tests/DynamicText.json", "Dynamic Text", "Checkered Flag") { animationView ->
+ val textDelegate = TextDelegate(animationView)
+ animationView.setTextDelegate(textDelegate)
+ textDelegate.setText("NAME", "\uD83C\uDFC1")
+ }
+
+ withAnimationView("Tests/DynamicText.json", "Dynamic Text", "Pirate Flag") { animationView ->
+ val textDelegate = TextDelegate(animationView)
+ animationView.setTextDelegate(textDelegate)
+ textDelegate.setText("NAME", "\uD83C\uDFF4\u200D☠️")
+ }
+
+ withAnimationView("Tests/DynamicText.json", "Dynamic Text", "3 Oclock") { animationView ->
+ val textDelegate = TextDelegate(animationView)
+ animationView.setTextDelegate(textDelegate)
+ textDelegate.setText("NAME", "\uD83D\uDD52")
+ }
+
+ withAnimationView("Tests/DynamicText.json", "Dynamic Text", "Woman frowning") { animationView ->
+ val textDelegate = TextDelegate(animationView)
+ animationView.setTextDelegate(textDelegate)
+ textDelegate.setText("NAME", "\uD83D\uDE4D\u200D♀️")
+ }
+
+ withAnimationView("Tests/DynamicText.json", "Dynamic Text", "Gay couple") { animationView ->
+ val textDelegate = TextDelegate(animationView)
+ animationView.setTextDelegate(textDelegate)
+ textDelegate.setText("NAME", "\uD83D\uDC68\u200D❤️\u200D\uD83D\uDC68️")
+ }
+
+ withAnimationView("Tests/DynamicText.json", "Dynamic Text", "Lesbian couple") { animationView ->
+ val textDelegate = TextDelegate(animationView)
+ animationView.setTextDelegate(textDelegate)
+ textDelegate.setText("NAME", "\uD83D\uDC69\u200D❤️\u200D\uD83D\uDC69️")
+ }
+
+ withAnimationView("Tests/DynamicText.json", "Dynamic Text", "Straight couple") { animationView ->
+ val textDelegate = TextDelegate(animationView)
+ animationView.setTextDelegate(textDelegate)
+ textDelegate.setText("NAME", "\uD83D\uDC91")
+ }
+ }
+
private suspend fun withDrawable(assetName: String, snapshotName: String, snapshotVariant: String, callback: (LottieDrawable) -> Unit) {
val result = LottieCompositionFactory.fromAssetSync(activity, assetName)
val composition = result.value
diff --git a/LottieSample/src/main/assets/Tests/DynamicText.json b/LottieSample/src/main/assets/Tests/DynamicText.json
new file mode 100644
index 0000000..fad053c
--- /dev/null
+++ b/LottieSample/src/main/assets/Tests/DynamicText.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
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 44df146..ab45531 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
@@ -8,6 +8,7 @@
import android.graphics.RectF;
import android.graphics.Typeface;
import androidx.annotation.Nullable;
+import androidx.collection.LongSparseArray;
import com.airbnb.lottie.LottieComposition;
import com.airbnb.lottie.LottieDrawable;
import com.airbnb.lottie.LottieProperty;
@@ -23,6 +24,7 @@
import com.airbnb.lottie.model.content.ShapeGroup;
import com.airbnb.lottie.utils.Utils;
import com.airbnb.lottie.value.LottieValueCallback;
+
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
@@ -30,7 +32,9 @@
import java.util.Map;
public class TextLayer extends BaseLayer {
- private final char[] tempCharArray = new char[1];
+ // Capacity is 2 because emojis are 2 characters. Some are longer in which case, the capacity will
+ // be expanded but that should be pretty rare.
+ private final StringBuilder stringBuilder = new StringBuilder(2);
private final RectF rectF = new RectF();
private final Matrix matrix = new Matrix();
private final Paint fillPaint = new Paint(Paint.ANTI_ALIAS_FLAG) {{
@@ -40,13 +44,18 @@
setStyle(Style.STROKE);
}};
private final Map<FontCharacter, List<ContentGroup>> contentsForCharacter = new HashMap<>();
+ private final LongSparseArray<String> codePointCache = new LongSparseArray<String>();
private final TextKeyframeAnimation textAnimation;
private final LottieDrawable lottieDrawable;
private final LottieComposition composition;
- @Nullable private BaseKeyframeAnimation<Integer, Integer> colorAnimation;
- @Nullable private BaseKeyframeAnimation<Integer, Integer> strokeColorAnimation;
- @Nullable private BaseKeyframeAnimation<Float, Float> strokeWidthAnimation;
- @Nullable private BaseKeyframeAnimation<Float, Float> trackingAnimation;
+ @Nullable
+ private BaseKeyframeAnimation<Integer, Integer> colorAnimation;
+ @Nullable
+ private BaseKeyframeAnimation<Integer, Integer> strokeColorAnimation;
+ @Nullable
+ private BaseKeyframeAnimation<Float, Float> strokeWidthAnimation;
+ @Nullable
+ private BaseKeyframeAnimation<Float, Float> trackingAnimation;
TextLayer(LottieDrawable lottieDrawable, Layer layerModel) {
super(lottieDrawable, layerModel);
@@ -83,13 +92,15 @@
}
}
- @Override public void getBounds(RectF outBounds, Matrix parentMatrix, boolean applyParents) {
+ @Override
+ public void getBounds(RectF outBounds, Matrix parentMatrix, boolean applyParents) {
super.getBounds(outBounds, parentMatrix, applyParents);
// TODO: use the correct text bounds.
outBounds.set(0, 0, composition.getBounds().width(), composition.getBounds().height());
}
- @Override void drawLayer(Canvas canvas, Matrix parentMatrix, int parentAlpha) {
+ @Override
+ void drawLayer(Canvas canvas, Matrix parentMatrix, int parentAlpha) {
canvas.save();
if (!lottieDrawable.useTextGlyphs()) {
canvas.setMatrix(parentMatrix);
@@ -171,7 +182,7 @@
}
private void drawGlyphTextLine(String text, DocumentData documentData, Matrix parentMatrix,
- Font font, Canvas canvas, float parentScale, float fontScale) {
+ Font font, Canvas canvas, float parentScale, float fontScale) {
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
int characterHash = FontCharacter.hashFor(c, font.getFamily(), font.getStyle());
@@ -245,11 +256,11 @@
}
private void drawFontTextLine(String text, DocumentData documentData, Canvas canvas, float parentScale) {
- for (int i = 0; i < text.length(); i++) {
- char character = text.charAt(i);
- drawCharacterFromFont(character, documentData, canvas);
- tempCharArray[0] = character;
- float charWidth = fillPaint.measureText(tempCharArray, 0, 1);
+ for (int i = 0; i < text.length(); ) {
+ String charString = codePointToString(text, i);
+ i += charString.length();
+ drawCharacterFromFont(charString, documentData, canvas);
+ float charWidth = fillPaint.measureText(charString, 0, 1);
// Add tracking
float tracking = documentData.tracking / 10f;
if (trackingAnimation != null) {
@@ -323,25 +334,24 @@
canvas.drawPath(path, paint);
}
- private void drawCharacterFromFont(char c, DocumentData documentData, Canvas canvas) {
- tempCharArray[0] = c;
+ private void drawCharacterFromFont(String character, DocumentData documentData, Canvas canvas) {
if (documentData.strokeOverFill) {
- drawCharacter(tempCharArray, fillPaint, canvas);
- drawCharacter(tempCharArray, strokePaint, canvas);
+ drawCharacter(character, fillPaint, canvas);
+ drawCharacter(character, strokePaint, canvas);
} else {
- drawCharacter(tempCharArray, strokePaint, canvas);
- drawCharacter(tempCharArray, fillPaint, canvas);
+ drawCharacter(character, strokePaint, canvas);
+ drawCharacter(character, fillPaint, canvas);
}
}
- private void drawCharacter(char[] character, Paint paint, Canvas canvas) {
+ private void drawCharacter(String character, Paint paint, Canvas canvas) {
if (paint.getColor() == Color.TRANSPARENT) {
return;
}
if (paint.getStyle() == Paint.Style.STROKE && paint.getStrokeWidth() == 0) {
return;
}
- canvas.drawText(character, 0, 1, 0, 0, paint);
+ canvas.drawText(character, 0, character.length(), 0, 0, paint);
}
private List<ContentGroup> getContentsForCharacter(FontCharacter character) {
@@ -359,6 +369,44 @@
return contents;
}
+ private String codePointToString(String text, int startIndex) {
+ int firstCodePoint = text.codePointAt(startIndex);
+ int firstCodePointLength = Character.charCount(firstCodePoint);
+ int key = firstCodePoint;
+ int index = startIndex + firstCodePointLength;
+ while (index < text.length()) {
+ int nextCodePoint = text.codePointAt(index);
+ if (!isModifier(nextCodePoint)) {
+ break;
+ }
+ int nextCodePointLength = Character.charCount(nextCodePoint);
+ index += nextCodePointLength;
+ key = key * 31 + nextCodePoint;
+ }
+
+ if (codePointCache.containsKey(key)) {
+ return codePointCache.get(key);
+ }
+
+ stringBuilder.setLength(0);
+ for (int i = startIndex; i < index; ) {
+ int codePoint = text.codePointAt(i);
+ stringBuilder.appendCodePoint(codePoint);
+ i += Character.charCount(codePoint);
+ }
+ String str = stringBuilder.toString();
+ codePointCache.put(key, str);
+ return str;
+ }
+
+ private boolean isModifier(int codePoint) {
+ return Character.getType(codePoint) == Character.FORMAT ||
+ Character.getType(codePoint) == Character.MODIFIER_SYMBOL ||
+ Character.getType(codePoint) == Character.NON_SPACING_MARK ||
+ Character.getType(codePoint) == Character.OTHER_SYMBOL ||
+ Character.getType(codePoint) == Character.SURROGATE;
+ }
+
@SuppressWarnings("unchecked")
@Override
public <T> void addValueCallback(T property, @Nullable LottieValueCallback<T> callback) {