Skip to content

Commit

Permalink
Add support for text range selectors (#2518)
Browse files Browse the repository at this point in the history
This adds support for the [text range selector](https://lottiefiles.github.io/lottie-docs/text/#text-range-selector) type, which allows a [text style](https://lottiefiles.github.io/lottie-docs/text/#text-style) to be applied to a certain range of the text, defined by a start, end, and offset.

The text range selector implementation currently has the following limitations:
- Only text layers drawn using a Font are supported. Adding support for the glyph draw path may be possible, but I don't have a sample Lottie file.
- Only one text range is currently supported per text layer, as the parser [drops all other entries in the array](https://github.com/airbnb/lottie-android/blob/c4cb2254eca3c70199f1de5e39e3872c8c42e473/lottie/src/main/java/com/airbnb/lottie/parser/LayerParser.java#L194-L196).
- Only index-based ranges are supported - percentage-based ranges are also allowed in the spec.
- Only ranges based on characters are supported. The [spec](https://lottiefiles.github.io/lottie-docs/constants/#text-based) allows characters, characters excluding spaces, words, and lines.
- Other options like easing (allows styling characters that are partially inside the range), randomize, and [shape](https://lottiefiles.github.io/lottie-docs/constants/#text-shape) of the range are not currently supported.

This also adds support for the opacity as an animatable text property which is applied multiplicatively with the transform opacity and the parent's alpha.

Partially addresses #485.


https://github.com/user-attachments/assets/bcfad060-482d-48d9-a578-297c4f143ba9


https://github.com/user-attachments/assets/211dc574-5ea1-4fa3-9f78-f87ee104ce85

Co-authored-by: Allen Chen <[email protected]>
  • Loading branch information
allenchen1154 and Allen Chen authored Jul 24, 2024
1 parent 328fc72 commit 71c1622
Show file tree
Hide file tree
Showing 10 changed files with 3,800 additions and 55 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,13 @@

public class AnimatableTextProperties {

@Nullable public final AnimatableColorValue color;
@Nullable public final AnimatableColorValue stroke;
@Nullable public final AnimatableFloatValue strokeWidth;
@Nullable public final AnimatableFloatValue tracking;
@Nullable public final AnimatableTextStyle textStyle;
@Nullable public final AnimatableTextRangeSelector rangeSelector;

public AnimatableTextProperties(@Nullable AnimatableColorValue color,
@Nullable AnimatableColorValue stroke, @Nullable AnimatableFloatValue strokeWidth,
@Nullable AnimatableFloatValue tracking) {
this.color = color;
this.stroke = stroke;
this.strokeWidth = strokeWidth;
this.tracking = tracking;
public AnimatableTextProperties(
@Nullable AnimatableTextStyle textStyle,
@Nullable AnimatableTextRangeSelector rangeSelector) {
this.textStyle = textStyle;
this.rangeSelector = rangeSelector;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.airbnb.lottie.model.animatable;

import androidx.annotation.Nullable;
import com.airbnb.lottie.model.content.TextRangeUnits;

/**
* Defines an animated range of text that should have an [AnimatableTextProperties] applied to it.
*/
public class AnimatableTextRangeSelector {
@Nullable public final AnimatableIntegerValue start;
@Nullable public final AnimatableIntegerValue end;
@Nullable public final AnimatableIntegerValue offset;
public final TextRangeUnits units;

public AnimatableTextRangeSelector(
@Nullable AnimatableIntegerValue start,
@Nullable AnimatableIntegerValue end,
@Nullable AnimatableIntegerValue offset,
TextRangeUnits units) {
this.start = start;
this.end = end;
this.offset = offset;
this.units = units;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.airbnb.lottie.model.animatable;

import androidx.annotation.Nullable;

public class AnimatableTextStyle {

@Nullable public final AnimatableColorValue color;
@Nullable public final AnimatableColorValue stroke;
@Nullable public final AnimatableFloatValue strokeWidth;
@Nullable public final AnimatableFloatValue tracking;
@Nullable public final AnimatableIntegerValue opacity;

public AnimatableTextStyle(
@Nullable AnimatableColorValue color,
@Nullable AnimatableColorValue stroke,
@Nullable AnimatableFloatValue strokeWidth,
@Nullable AnimatableFloatValue tracking,
@Nullable AnimatableIntegerValue opacity) {
this.color = color;
this.stroke = stroke;
this.strokeWidth = strokeWidth;
this.tracking = tracking;
this.opacity = opacity;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.airbnb.lottie.model.content;

public enum TextRangeUnits {
PERCENT,
INDEX
}
151 changes: 121 additions & 30 deletions lottie/src/main/java/com/airbnb/lottie/model/layer/TextLayer.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import com.airbnb.lottie.model.FontCharacter;
import com.airbnb.lottie.model.animatable.AnimatableTextProperties;
import com.airbnb.lottie.model.content.ShapeGroup;
import com.airbnb.lottie.model.content.TextRangeUnits;
import com.airbnb.lottie.utils.Utils;
import com.airbnb.lottie.value.LottieValueCallback;

Expand Down Expand Up @@ -56,6 +57,7 @@ public class TextLayer extends BaseLayer {
private final TextKeyframeAnimation textAnimation;
private final LottieDrawable lottieDrawable;
private final LottieComposition composition;
private TextRangeUnits textRangeUnits = TextRangeUnits.INDEX;
@Nullable
private BaseKeyframeAnimation<Integer, Integer> colorAnimation;
@Nullable
Expand All @@ -73,9 +75,17 @@ public class TextLayer extends BaseLayer {
@Nullable
private BaseKeyframeAnimation<Float, Float> trackingCallbackAnimation;
@Nullable
private BaseKeyframeAnimation<Integer, Integer> opacityAnimation;
@Nullable
private BaseKeyframeAnimation<Float, Float> textSizeCallbackAnimation;
@Nullable
private BaseKeyframeAnimation<Typeface, Typeface> typefaceCallbackAnimation;
@Nullable
private BaseKeyframeAnimation<Integer, Integer> textRangeStartAnimation;
@Nullable
private BaseKeyframeAnimation<Integer, Integer> textRangeEndAnimation;
@Nullable
private BaseKeyframeAnimation<Integer, Integer> textRangeOffsetAnimation;

TextLayer(LottieDrawable lottieDrawable, Layer layerModel) {
super(lottieDrawable, layerModel);
Expand All @@ -87,29 +97,57 @@ public class TextLayer extends BaseLayer {
addAnimation(textAnimation);

AnimatableTextProperties textProperties = layerModel.getTextProperties();
if (textProperties != null && textProperties.color != null) {
colorAnimation = textProperties.color.createAnimation();
if (textProperties != null && textProperties.textStyle != null && textProperties.textStyle.color != null) {
colorAnimation = textProperties.textStyle.color.createAnimation();
colorAnimation.addUpdateListener(this);
addAnimation(colorAnimation);
}

if (textProperties != null && textProperties.stroke != null) {
strokeColorAnimation = textProperties.stroke.createAnimation();
if (textProperties != null && textProperties.textStyle != null && textProperties.textStyle.stroke != null) {
strokeColorAnimation = textProperties.textStyle.stroke.createAnimation();
strokeColorAnimation.addUpdateListener(this);
addAnimation(strokeColorAnimation);
}

if (textProperties != null && textProperties.strokeWidth != null) {
strokeWidthAnimation = textProperties.strokeWidth.createAnimation();
if (textProperties != null && textProperties.textStyle != null && textProperties.textStyle.strokeWidth != null) {
strokeWidthAnimation = textProperties.textStyle.strokeWidth.createAnimation();
strokeWidthAnimation.addUpdateListener(this);
addAnimation(strokeWidthAnimation);
}

if (textProperties != null && textProperties.tracking != null) {
trackingAnimation = textProperties.tracking.createAnimation();
if (textProperties != null && textProperties.textStyle != null && textProperties.textStyle.tracking != null) {
trackingAnimation = textProperties.textStyle.tracking.createAnimation();
trackingAnimation.addUpdateListener(this);
addAnimation(trackingAnimation);
}

if (textProperties != null && textProperties.textStyle != null && textProperties.textStyle.opacity != null) {
opacityAnimation = textProperties.textStyle.opacity.createAnimation();
opacityAnimation.addUpdateListener(this);
addAnimation(opacityAnimation);
}

if (textProperties != null && textProperties.rangeSelector != null && textProperties.rangeSelector.start != null) {
textRangeStartAnimation = textProperties.rangeSelector.start.createAnimation();
textRangeStartAnimation.addUpdateListener(this);
addAnimation(textRangeStartAnimation);
}

if (textProperties != null && textProperties.rangeSelector != null && textProperties.rangeSelector.end != null) {
textRangeEndAnimation = textProperties.rangeSelector.end.createAnimation();
textRangeEndAnimation.addUpdateListener(this);
addAnimation(textRangeEndAnimation);
}

if (textProperties != null && textProperties.rangeSelector != null && textProperties.rangeSelector.offset != null) {
textRangeOffsetAnimation = textProperties.rangeSelector.offset.createAnimation();
textRangeOffsetAnimation.addUpdateListener(this);
addAnimation(textRangeOffsetAnimation);
}

if (textProperties != null && textProperties.rangeSelector != null) {
textRangeUnits = textProperties.rangeSelector.units;
}
}

@Override
Expand All @@ -129,49 +167,86 @@ void drawLayer(Canvas canvas, Matrix parentMatrix, int parentAlpha) {
canvas.save();
canvas.concat(parentMatrix);

configurePaint(documentData, parentAlpha);
configurePaint(documentData, parentAlpha, 0);

if (lottieDrawable.useTextGlyphs()) {
drawTextWithGlyphs(documentData, parentMatrix, font, canvas);
drawTextWithGlyphs(documentData, parentMatrix, font, canvas, parentAlpha);
} else {
drawTextWithFont(documentData, font, canvas);
drawTextWithFont(documentData, font, canvas, parentAlpha);
}

canvas.restore();
}

private void configurePaint(DocumentData documentData, int parentAlpha) {
if (colorCallbackAnimation != null) {
/**
* Configures the [fillPaint] and [strokePaint] used for drawing based on currently active text ranges.
*
* @param parentAlpha A value from 0 to 255 indicating the alpha of the parented layer.
*/
private void configurePaint(DocumentData documentData, int parentAlpha, int indexInDocument) {
if (colorCallbackAnimation != null) { // dynamic property takes priority
fillPaint.setColor(colorCallbackAnimation.getValue());
} else if (colorAnimation != null) {
} else if (colorAnimation != null && isIndexInRangeSelection(indexInDocument)) {
fillPaint.setColor(colorAnimation.getValue());
} else {
} else { // fall back to the document color
fillPaint.setColor(documentData.color);
}

if (strokeColorCallbackAnimation != null) {
strokePaint.setColor(strokeColorCallbackAnimation.getValue());
} else if (strokeColorAnimation != null) {
} else if (strokeColorAnimation != null && isIndexInRangeSelection(indexInDocument)) {
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;

// These opacity values are in the range 0 to 100
int transformOpacity = transform.getOpacity() == null ? 100 : transform.getOpacity().getValue();
int textRangeOpacity = opacityAnimation != null && isIndexInRangeSelection(indexInDocument) ? opacityAnimation.getValue() : 100;

// This alpha value needs to be in the range 0 to 255 to be applied to the Paint instances.
// We map the layer transform's opacity into that range and multiply it by the fractional opacity of the text range and the parent.
int alpha = Math.round((transformOpacity * 255f / 100f)
* (textRangeOpacity / 100f)
* parentAlpha / 255f);
fillPaint.setAlpha(alpha);
strokePaint.setAlpha(alpha);

if (strokeWidthCallbackAnimation != null) {
strokePaint.setStrokeWidth(strokeWidthCallbackAnimation.getValue());
} else if (strokeWidthAnimation != null) {
} else if (strokeWidthAnimation != null && isIndexInRangeSelection(indexInDocument)) {
strokePaint.setStrokeWidth(strokeWidthAnimation.getValue());
} else {
strokePaint.setStrokeWidth(documentData.strokeWidth * Utils.dpScale());
}
}

private boolean isIndexInRangeSelection(int indexInDocument) {
int textLength = textAnimation.getValue().text.length();
if (textRangeStartAnimation != null && textRangeEndAnimation != null) {
// After effects supports reversed text ranges where the start index is greater than the end index.
// For the purposes of determining if the given index is inside of the range, we take the start as the smaller value.
int rangeStart = Math.min(textRangeStartAnimation.getValue(), textRangeEndAnimation.getValue());
int rangeEnd = Math.max(textRangeStartAnimation.getValue(), textRangeEndAnimation.getValue());

if (textRangeOffsetAnimation != null) {
int offset = textRangeOffsetAnimation.getValue();
rangeStart += offset;
rangeEnd += offset;
}

if (textRangeUnits == TextRangeUnits.INDEX) {
return indexInDocument >= rangeStart && indexInDocument < rangeEnd;
} else {
float currentIndexAsPercent = indexInDocument / (float) textLength * 100;
return currentIndexAsPercent >= rangeStart && currentIndexAsPercent < rangeEnd;
}
}
return true;
}

private void drawTextWithGlyphs(
DocumentData documentData, Matrix parentMatrix, Font font, Canvas canvas) {
DocumentData documentData, Matrix parentMatrix, Font font, Canvas canvas, int parentAlpha) {
float textSize;
if (textSizeCallbackAnimation != null) {
textSize = textSizeCallbackAnimation.getValue();
Expand Down Expand Up @@ -205,7 +280,7 @@ private void drawTextWithGlyphs(
canvas.save();

if (offsetCanvas(canvas, documentData, lineIndex, line.width)) {
drawGlyphTextLine(line.text, documentData, font, canvas, parentScale, fontScale, tracking);
drawGlyphTextLine(line.text, documentData, font, canvas, parentScale, fontScale, tracking, parentAlpha);
}

canvas.restore();
Expand All @@ -214,7 +289,7 @@ private void drawTextWithGlyphs(
}

private void drawGlyphTextLine(String text, DocumentData documentData,
Font font, Canvas canvas, float parentScale, float fontScale, float tracking) {
Font font, Canvas canvas, float parentScale, float fontScale, float tracking, int parentAlpha) {
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
int characterHash = FontCharacter.hashFor(c, font.getFamily(), font.getStyle());
Expand All @@ -223,13 +298,13 @@ private void drawGlyphTextLine(String text, DocumentData documentData,
// Something is wrong. Potentially, they didn't export the text as a glyph.
continue;
}
drawCharacterAsGlyph(character, fontScale, documentData, canvas);
drawCharacterAsGlyph(character, fontScale, documentData, canvas, i, parentAlpha);
float tx = (float) character.getWidth() * fontScale * Utils.dpScale() + tracking;
canvas.translate(tx, 0);
}
}

private void drawTextWithFont(DocumentData documentData, Font font, Canvas canvas) {
private void drawTextWithFont(DocumentData documentData, Font font, Canvas canvas, int parentAlpha) {
Typeface typeface = getTypeface(font);
if (typeface == null) {
return;
Expand Down Expand Up @@ -263,6 +338,7 @@ private void drawTextWithFont(DocumentData documentData, Font font, Canvas canva
List<String> textLines = getTextLines(text);
int textLineCount = textLines.size();
int lineIndex = -1;
int characterIndexAtStartOfLine = 0;
for (int i = 0; i < textLineCount; i++) {
String textLine = textLines.get(i);
float boxWidth = documentData.boxSize == null ? 0f : documentData.boxSize.x;
Expand All @@ -274,9 +350,11 @@ private void drawTextWithFont(DocumentData documentData, Font font, Canvas canva
canvas.save();

if (offsetCanvas(canvas, documentData, lineIndex, line.width)) {
drawFontTextLine(line.text, documentData, canvas, tracking);
drawFontTextLine(line.text, documentData, canvas, tracking, characterIndexAtStartOfLine, parentAlpha);
}

characterIndexAtStartOfLine += line.text.length();

canvas.restore();
}
}
Expand Down Expand Up @@ -331,14 +409,23 @@ private List<String> getTextLines(String text) {
return Arrays.asList(textLinesArray);
}

private void drawFontTextLine(String text, DocumentData documentData, Canvas canvas, float tracking) {
/**
* @param characterIndexAtStartOfLine The index within the overall document of the character at the start of the line
* @param parentAlpha
*/
private void drawFontTextLine(String text,
DocumentData documentData,
Canvas canvas,
float tracking,
int characterIndexAtStartOfLine,
int parentAlpha) {
for (int i = 0; i < text.length(); ) {
String charString = codePointToString(text, i);
i += charString.length();
drawCharacterFromFont(charString, documentData, canvas);
drawCharacterFromFont(charString, documentData, canvas, characterIndexAtStartOfLine + i, parentAlpha);
float charWidth = fillPaint.measureText(charString);
float tx = charWidth + tracking;
canvas.translate(tx, 0);
i += charString.length();
}
}

Expand Down Expand Up @@ -430,7 +517,10 @@ private void drawCharacterAsGlyph(
FontCharacter character,
float fontScale,
DocumentData documentData,
Canvas canvas) {
Canvas canvas,
int indexInDocument,
int parentAlpha) {
configurePaint(documentData, parentAlpha, indexInDocument);
List<ContentGroup> contentGroups = getContentsForCharacter(character);
for (int j = 0; j < contentGroups.size(); j++) {
Path path = contentGroups.get(j).getPath();
Expand Down Expand Up @@ -459,7 +549,8 @@ private void drawGlyph(Path path, Paint paint, Canvas canvas) {
canvas.drawPath(path, paint);
}

private void drawCharacterFromFont(String character, DocumentData documentData, Canvas canvas) {
private void drawCharacterFromFont(String character, DocumentData documentData, Canvas canvas, int indexInDocument, int parentAlpha) {
configurePaint(documentData, parentAlpha, indexInDocument);
if (documentData.strokeOverFill) {
drawCharacter(character, fillPaint, canvas);
drawCharacter(character, strokePaint, canvas);
Expand Down
Loading

0 comments on commit 71c1622

Please sign in to comment.