blob: 211f1d6a0ed246c0123da5648aa5f5de110b8191 [file] [log] [blame]
package com.airbnb.lottie.model.layer;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.PointF;
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;
import com.airbnb.lottie.TextDelegate;
import com.airbnb.lottie.animation.content.ContentGroup;
import com.airbnb.lottie.animation.keyframe.BaseKeyframeAnimation;
import com.airbnb.lottie.animation.keyframe.TextKeyframeAnimation;
import com.airbnb.lottie.animation.keyframe.ValueCallbackKeyframeAnimation;
import com.airbnb.lottie.model.DocumentData;
import com.airbnb.lottie.model.Font;
import com.airbnb.lottie.model.FontCharacter;
import com.airbnb.lottie.model.animatable.AnimatableTextProperties;
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;
import java.util.List;
import java.util.Map;
public class TextLayer extends BaseLayer {
// 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) {{
setStyle(Style.FILL);
}};
private final Paint strokePaint = new Paint(Paint.ANTI_ALIAS_FLAG) {{
setStyle(Style.STROKE);
}};
private final Map<FontCharacter, List<ContentGroup>> contentsForCharacter = new HashMap<>();
private final LongSparseArray<String> codePointCache = new LongSparseArray<>();
/**
* If this is paragraph text, one line may wrap depending on the size of the document data box.
*/
private final List<TextSubLine> textSubLines = new ArrayList<>();
private final TextKeyframeAnimation textAnimation;
private final LottieDrawable lottieDrawable;
private final LottieComposition composition;
@Nullable
private BaseKeyframeAnimation<Integer, Integer> colorAnimation;
@Nullable
private BaseKeyframeAnimation<Integer, Integer> colorCallbackAnimation;
@Nullable
private BaseKeyframeAnimation<Integer, Integer> strokeColorAnimation;
@Nullable
private BaseKeyframeAnimation<Integer, Integer> strokeColorCallbackAnimation;
@Nullable
private BaseKeyframeAnimation<Float, Float> strokeWidthAnimation;
@Nullable
private BaseKeyframeAnimation<Float, Float> strokeWidthCallbackAnimation;
@Nullable
private BaseKeyframeAnimation<Float, Float> trackingAnimation;
@Nullable
private BaseKeyframeAnimation<Float, Float> trackingCallbackAnimation;
@Nullable
private BaseKeyframeAnimation<Float, Float> textSizeCallbackAnimation;
@Nullable
private BaseKeyframeAnimation<Typeface, Typeface> typefaceCallbackAnimation;
TextLayer(LottieDrawable lottieDrawable, Layer layerModel) {
super(lottieDrawable, layerModel);
this.lottieDrawable = lottieDrawable;
composition = layerModel.getComposition();
//noinspection ConstantConditions
textAnimation = layerModel.getText().createAnimation();
textAnimation.addUpdateListener(this);
addAnimation(textAnimation);
AnimatableTextProperties textProperties = layerModel.getTextProperties();
if (textProperties != null && textProperties.color != null) {
colorAnimation = textProperties.color.createAnimation();
colorAnimation.addUpdateListener(this);
addAnimation(colorAnimation);
}
if (textProperties != null && textProperties.stroke != null) {
strokeColorAnimation = textProperties.stroke.createAnimation();
strokeColorAnimation.addUpdateListener(this);
addAnimation(strokeColorAnimation);
}
if (textProperties != null && textProperties.strokeWidth != null) {
strokeWidthAnimation = textProperties.strokeWidth.createAnimation();
strokeWidthAnimation.addUpdateListener(this);
addAnimation(strokeWidthAnimation);
}
if (textProperties != null && textProperties.tracking != null) {
trackingAnimation = textProperties.tracking.createAnimation();
trackingAnimation.addUpdateListener(this);
addAnimation(trackingAnimation);
}
}
@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) {
DocumentData documentData = textAnimation.getValue();
Font font = composition.getFonts().get(documentData.fontName);
if (font == null) {
return;
}
canvas.save();
canvas.concat(parentMatrix);
configurePaint(documentData, parentAlpha);
if (lottieDrawable.useTextGlyphs()) {
drawTextWithGlyphs(documentData, parentMatrix, font, canvas);
} else {
drawTextWithFont(documentData, font, canvas);
}
canvas.restore();
}
private void configurePaint(DocumentData documentData, int parentAlpha) {
if (colorCallbackAnimation != null) {
fillPaint.setColor(colorCallbackAnimation.getValue());
} else if (colorAnimation != null) {
fillPaint.setColor(colorAnimation.getValue());
} else {
fillPaint.setColor(documentData.color);
}
if (strokeColorCallbackAnimation != null) {
strokePaint.setColor(strokeColorCallbackAnimation.getValue());
} else if (strokeColorAnimation != null) {
strokePaint.setColor(strokeColorAnimation.getValue());
} else {
strokePaint.setColor(documentData.strokeColor);
}
int opacity = transform.getOpacity() == null ? 100 : transform.getOpacity().getValue();
int alpha = opacity * 255 / 100 * parentAlpha / 255;
fillPaint.setAlpha(alpha);
strokePaint.setAlpha(alpha);
if (strokeWidthCallbackAnimation != null) {
strokePaint.setStrokeWidth(strokeWidthCallbackAnimation.getValue());
} else if (strokeWidthAnimation != null) {
strokePaint.setStrokeWidth(strokeWidthAnimation.getValue());
} else {
strokePaint.setStrokeWidth(documentData.strokeWidth * Utils.dpScale());
}
}
private void drawTextWithGlyphs(
DocumentData documentData, Matrix parentMatrix, Font font, Canvas canvas) {
float textSize;
if (textSizeCallbackAnimation != null) {
textSize = textSizeCallbackAnimation.getValue();
} else {
textSize = documentData.size;
}
float fontScale = textSize / 100f;
float parentScale = Utils.getScale(parentMatrix);
String text = documentData.text;
// Split full text in multiple lines
List<String> textLines = getTextLines(text);
int textLineCount = textLines.size();
// Add tracking
float tracking = documentData.tracking / 10f;
if (trackingCallbackAnimation != null) {
tracking += trackingCallbackAnimation.getValue();
} else if (trackingAnimation != null) {
tracking += trackingAnimation.getValue();
}
int lineIndex = -1;
for (int i = 0; i < textLineCount; i++) {
String textLine = textLines.get(i);
float boxWidth = documentData.boxSize == null ? 0f : documentData.boxSize.x;
List<TextSubLine> lines = splitGlyphTextIntoLines(textLine, boxWidth, font, fontScale, tracking, true);
for (int j = 0; j < lines.size(); j++) {
TextSubLine line = lines.get(j);
lineIndex++;
canvas.save();
if (offsetCanvas(canvas, documentData, lineIndex, line.width)) {
drawGlyphTextLine(line.text, documentData, font, canvas, parentScale, fontScale, tracking);
}
canvas.restore();
}
}
}
private void drawGlyphTextLine(String text, DocumentData documentData,
Font font, Canvas canvas, float parentScale, float fontScale, float tracking) {
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
int characterHash = FontCharacter.hashFor(c, font.getFamily(), font.getStyle());
FontCharacter character = composition.getCharacters().get(characterHash);
if (character == null) {
// Something is wrong. Potentially, they didn't export the text as a glyph.
continue;
}
drawCharacterAsGlyph(character, fontScale, documentData, canvas);
float tx = (float) character.getWidth() * fontScale * Utils.dpScale() + tracking;
canvas.translate(tx, 0);
}
}
private void drawTextWithFont(DocumentData documentData, Font font, Canvas canvas) {
Typeface typeface = getTypeface(font);
if (typeface == null) {
return;
}
String text = documentData.text;
TextDelegate textDelegate = lottieDrawable.getTextDelegate();
if (textDelegate != null) {
text = textDelegate.getTextInternal(getName(), text);
}
fillPaint.setTypeface(typeface);
float textSize;
if (textSizeCallbackAnimation != null) {
textSize = textSizeCallbackAnimation.getValue();
} else {
textSize = documentData.size;
}
fillPaint.setTextSize(textSize * Utils.dpScale());
strokePaint.setTypeface(fillPaint.getTypeface());
strokePaint.setTextSize(fillPaint.getTextSize());
// Calculate tracking
float tracking = documentData.tracking / 10f;
if (trackingCallbackAnimation != null) {
tracking += trackingCallbackAnimation.getValue();
} else if (trackingAnimation != null) {
tracking += trackingAnimation.getValue();
}
tracking = tracking * Utils.dpScale() * textSize / 100.0f;
// Split full text in multiple lines
List<String> textLines = getTextLines(text);
int textLineCount = textLines.size();
int lineIndex = -1;
for (int i = 0; i < textLineCount; i++) {
String textLine = textLines.get(i);
float boxWidth = documentData.boxSize == null ? 0f : documentData.boxSize.x;
List<TextSubLine> lines = splitGlyphTextIntoLines(textLine, boxWidth, font, 0f, tracking, false);
for (int j = 0; j < lines.size(); j++) {
TextSubLine line = lines.get(j);
lineIndex++;
canvas.save();
if (offsetCanvas(canvas, documentData, lineIndex, line.width)) {
drawFontTextLine(line.text, documentData, canvas, tracking);
}
canvas.restore();
}
}
}
private boolean offsetCanvas(Canvas canvas, DocumentData documentData, int lineIndex, float lineWidth) {
PointF position = documentData.boxPosition;
PointF size = documentData.boxSize;
float dpScale = Utils.dpScale();
float lineStartY = position == null ? 0f : documentData.lineHeight * dpScale + position.y;
float lineOffset = (lineIndex * documentData.lineHeight * dpScale) + lineStartY;
if (lottieDrawable.getClipTextToBoundingBox() && size != null && position != null && lineOffset >= position.y + size.y + documentData.size) {
return false;
}
float lineStart = position == null ? 0f : position.x;
float boxWidth = size == null ? 0f : size.x;
switch (documentData.justification) {
case LEFT_ALIGN:
canvas.translate(lineStart, lineOffset);
break;
case RIGHT_ALIGN:
canvas.translate(lineStart + boxWidth - lineWidth, lineOffset);
break;
case CENTER:
canvas.translate(lineStart + boxWidth / 2f - lineWidth / 2f, lineOffset);
break;
}
return true;
}
@Nullable
private Typeface getTypeface(Font font) {
if (typefaceCallbackAnimation != null) {
Typeface callbackTypeface = typefaceCallbackAnimation.getValue();
if (callbackTypeface != null) {
return callbackTypeface;
}
}
Typeface drawableTypeface = lottieDrawable.getTypeface(font);
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")
.replaceAll("\u0003", "\r")
.replaceAll("\n", "\r");
String[] textLinesArray = formattedText.split("\r");
return Arrays.asList(textLinesArray);
}
private void drawFontTextLine(String text, DocumentData documentData, Canvas canvas, float tracking) {
for (int i = 0; i < text.length(); ) {
String charString = codePointToString(text, i);
i += charString.length();
drawCharacterFromFont(charString, documentData, canvas);
float charWidth = fillPaint.measureText(charString);
float tx = charWidth + tracking;
canvas.translate(tx, 0);
}
}
private List<TextSubLine> splitGlyphTextIntoLines(String textLine, float boxWidth, Font font, float fontScale, float tracking,
boolean usingGlyphs) {
int lineCount = 0;
float currentLineWidth = 0;
int currentLineStartIndex = 0;
int currentWordStartIndex = 0;
float currentWordWidth = 0f;
boolean nextCharacterStartsWord = false;
// The measured size of a space.
float spaceWidth = 0f;
for (int i = 0; i < textLine.length(); i++) {
char c = textLine.charAt(i);
float currentCharWidth;
if (usingGlyphs) {
int characterHash = FontCharacter.hashFor(c, font.getFamily(), font.getStyle());
FontCharacter character = composition.getCharacters().get(characterHash);
if (character == null) {
continue;
}
currentCharWidth = (float) character.getWidth() * fontScale * Utils.dpScale() + tracking;
} else {
currentCharWidth = fillPaint.measureText(textLine.substring(i, i + 1)) + tracking;
}
if (c == ' ') {
spaceWidth = currentCharWidth;
nextCharacterStartsWord = true;
} else if (nextCharacterStartsWord) {
nextCharacterStartsWord = false;
currentWordStartIndex = i;
currentWordWidth = currentCharWidth;
} else {
currentWordWidth += currentCharWidth;
}
currentLineWidth += currentCharWidth;
if (boxWidth > 0f && currentLineWidth >= boxWidth) {
if (c == ' ') {
// Spaces at the end of a line don't do anything. Ignore it.
// The next non-space character will hit the conditions below.
continue;
}
TextSubLine subLine = ensureEnoughSubLines(++lineCount);
if (currentWordStartIndex == currentLineStartIndex) {
// Only word on line is wider than box, start wrapping mid-word.
String substr = textLine.substring(currentLineStartIndex, i);
String trimmed = substr.trim();
float trimmedSpace = (trimmed.length() - substr.length()) * spaceWidth;
subLine.set(trimmed, currentLineWidth - currentCharWidth - trimmedSpace);
currentLineStartIndex = i;
currentLineWidth = currentCharWidth;
currentWordStartIndex = currentLineStartIndex;
currentWordWidth = currentCharWidth;
} else {
String substr = textLine.substring(currentLineStartIndex, currentWordStartIndex - 1);
String trimmed = substr.trim();
float trimmedSpace = (substr.length() - trimmed.length()) * spaceWidth;
subLine.set(trimmed, currentLineWidth - currentWordWidth - trimmedSpace - spaceWidth);
currentLineStartIndex = currentWordStartIndex;
currentLineWidth = currentWordWidth;
}
}
}
if (currentLineWidth > 0f) {
TextSubLine line = ensureEnoughSubLines(++lineCount);
line.set(textLine.substring(currentLineStartIndex), currentLineWidth);
}
return textSubLines.subList(0, lineCount);
}
/**
* Elements are reused and not deleted to save allocations.
*/
private TextSubLine ensureEnoughSubLines(int numLines) {
for (int i = textSubLines.size(); i < numLines; i++) {
textSubLines.add(new TextSubLine());
}
return textSubLines.get(numLines - 1);
}
private void drawCharacterAsGlyph(
FontCharacter character,
float fontScale,
DocumentData documentData,
Canvas canvas) {
List<ContentGroup> contentGroups = getContentsForCharacter(character);
for (int j = 0; j < contentGroups.size(); j++) {
Path path = contentGroups.get(j).getPath();
path.computeBounds(rectF, false);
matrix.reset();
matrix.preTranslate(0, -documentData.baselineShift * Utils.dpScale());
matrix.preScale(fontScale, fontScale);
path.transform(matrix);
if (documentData.strokeOverFill) {
drawGlyph(path, fillPaint, canvas);
drawGlyph(path, strokePaint, canvas);
} else {
drawGlyph(path, strokePaint, canvas);
drawGlyph(path, fillPaint, canvas);
}
}
}
private void drawGlyph(Path path, Paint paint, Canvas canvas) {
if (paint.getColor() == Color.TRANSPARENT) {
return;
}
if (paint.getStyle() == Paint.Style.STROKE && paint.getStrokeWidth() == 0) {
return;
}
canvas.drawPath(path, paint);
}
private void drawCharacterFromFont(String character, DocumentData documentData, Canvas canvas) {
if (documentData.strokeOverFill) {
drawCharacter(character, fillPaint, canvas);
drawCharacter(character, strokePaint, canvas);
} else {
drawCharacter(character, strokePaint, canvas);
drawCharacter(character, fillPaint, 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, character.length(), 0, 0, paint);
}
private List<ContentGroup> getContentsForCharacter(FontCharacter character) {
if (contentsForCharacter.containsKey(character)) {
return contentsForCharacter.get(character);
}
List<ShapeGroup> shapes = character.getShapes();
int size = shapes.size();
List<ContentGroup> contents = new ArrayList<>(size);
for (int i = 0; i < size; i++) {
ShapeGroup sg = shapes.get(i);
contents.add(new ContentGroup(lottieDrawable, this, sg, composition));
}
contentsForCharacter.put(character, contents);
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.DIRECTIONALITY_NONSPACING_MARK ||
Character.getType(codePoint) == Character.SURROGATE;
}
@SuppressWarnings("unchecked")
@Override
public <T> void addValueCallback(T property, @Nullable LottieValueCallback<T> callback) {
super.addValueCallback(property, callback);
if (property == LottieProperty.COLOR) {
if (colorCallbackAnimation != null) {
removeAnimation(colorCallbackAnimation);
}
if (callback == null) {
colorCallbackAnimation = null;
} else {
colorCallbackAnimation = new ValueCallbackKeyframeAnimation<>((LottieValueCallback<Integer>) callback);
colorCallbackAnimation.addUpdateListener(this);
addAnimation(colorCallbackAnimation);
}
} else if (property == LottieProperty.STROKE_COLOR) {
if (strokeColorCallbackAnimation != null) {
removeAnimation(strokeColorCallbackAnimation);
}
if (callback == null) {
strokeColorCallbackAnimation = null;
} else {
strokeColorCallbackAnimation = new ValueCallbackKeyframeAnimation<>((LottieValueCallback<Integer>) callback);
strokeColorCallbackAnimation.addUpdateListener(this);
addAnimation(strokeColorCallbackAnimation);
}
} else if (property == LottieProperty.STROKE_WIDTH) {
if (strokeWidthCallbackAnimation != null) {
removeAnimation(strokeWidthCallbackAnimation);
}
if (callback == null) {
strokeWidthCallbackAnimation = null;
} else {
strokeWidthCallbackAnimation = new ValueCallbackKeyframeAnimation<>((LottieValueCallback<Float>) callback);
strokeWidthCallbackAnimation.addUpdateListener(this);
addAnimation(strokeWidthCallbackAnimation);
}
} else if (property == LottieProperty.TEXT_TRACKING) {
if (trackingCallbackAnimation != null) {
removeAnimation(trackingCallbackAnimation);
}
if (callback == null) {
trackingCallbackAnimation = null;
} else {
trackingCallbackAnimation = new ValueCallbackKeyframeAnimation<>((LottieValueCallback<Float>) callback);
trackingCallbackAnimation.addUpdateListener(this);
addAnimation(trackingCallbackAnimation);
}
} else if (property == LottieProperty.TEXT_SIZE) {
if (textSizeCallbackAnimation != null) {
removeAnimation(textSizeCallbackAnimation);
}
if (callback == null) {
textSizeCallbackAnimation = null;
} else {
textSizeCallbackAnimation = new ValueCallbackKeyframeAnimation<>((LottieValueCallback<Float>) callback);
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);
}
} else if (property == LottieProperty.TEXT) {
textAnimation.setStringValueCallback((LottieValueCallback<String>) callback);
}
}
private static class TextSubLine {
private String text = "";
private float width = 0f;
void set(String text, float width) {
this.text = text;
this.width = width;
}
}
}