Skip to content

Commit 71c1622

Browse files
allenchen1154Allen Chen
and
Allen Chen
authored
Add support for text range selectors (#2518)
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]>
1 parent 328fc72 commit 71c1622

File tree

10 files changed

+3800
-55
lines changed

10 files changed

+3800
-55
lines changed

lottie/src/main/java/com/airbnb/lottie/model/animatable/AnimatableTextProperties.java

+7-11
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,13 @@
44

55
public class AnimatableTextProperties {
66

7-
@Nullable public final AnimatableColorValue color;
8-
@Nullable public final AnimatableColorValue stroke;
9-
@Nullable public final AnimatableFloatValue strokeWidth;
10-
@Nullable public final AnimatableFloatValue tracking;
7+
@Nullable public final AnimatableTextStyle textStyle;
8+
@Nullable public final AnimatableTextRangeSelector rangeSelector;
119

12-
public AnimatableTextProperties(@Nullable AnimatableColorValue color,
13-
@Nullable AnimatableColorValue stroke, @Nullable AnimatableFloatValue strokeWidth,
14-
@Nullable AnimatableFloatValue tracking) {
15-
this.color = color;
16-
this.stroke = stroke;
17-
this.strokeWidth = strokeWidth;
18-
this.tracking = tracking;
10+
public AnimatableTextProperties(
11+
@Nullable AnimatableTextStyle textStyle,
12+
@Nullable AnimatableTextRangeSelector rangeSelector) {
13+
this.textStyle = textStyle;
14+
this.rangeSelector = rangeSelector;
1915
}
2016
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.airbnb.lottie.model.animatable;
2+
3+
import androidx.annotation.Nullable;
4+
import com.airbnb.lottie.model.content.TextRangeUnits;
5+
6+
/**
7+
* Defines an animated range of text that should have an [AnimatableTextProperties] applied to it.
8+
*/
9+
public class AnimatableTextRangeSelector {
10+
@Nullable public final AnimatableIntegerValue start;
11+
@Nullable public final AnimatableIntegerValue end;
12+
@Nullable public final AnimatableIntegerValue offset;
13+
public final TextRangeUnits units;
14+
15+
public AnimatableTextRangeSelector(
16+
@Nullable AnimatableIntegerValue start,
17+
@Nullable AnimatableIntegerValue end,
18+
@Nullable AnimatableIntegerValue offset,
19+
TextRangeUnits units) {
20+
this.start = start;
21+
this.end = end;
22+
this.offset = offset;
23+
this.units = units;
24+
}
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.airbnb.lottie.model.animatable;
2+
3+
import androidx.annotation.Nullable;
4+
5+
public class AnimatableTextStyle {
6+
7+
@Nullable public final AnimatableColorValue color;
8+
@Nullable public final AnimatableColorValue stroke;
9+
@Nullable public final AnimatableFloatValue strokeWidth;
10+
@Nullable public final AnimatableFloatValue tracking;
11+
@Nullable public final AnimatableIntegerValue opacity;
12+
13+
public AnimatableTextStyle(
14+
@Nullable AnimatableColorValue color,
15+
@Nullable AnimatableColorValue stroke,
16+
@Nullable AnimatableFloatValue strokeWidth,
17+
@Nullable AnimatableFloatValue tracking,
18+
@Nullable AnimatableIntegerValue opacity) {
19+
this.color = color;
20+
this.stroke = stroke;
21+
this.strokeWidth = strokeWidth;
22+
this.tracking = tracking;
23+
this.opacity = opacity;
24+
}
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package com.airbnb.lottie.model.content;
2+
3+
public enum TextRangeUnits {
4+
PERCENT,
5+
INDEX
6+
}

lottie/src/main/java/com/airbnb/lottie/model/layer/TextLayer.java

+121-30
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import com.airbnb.lottie.model.FontCharacter;
2626
import com.airbnb.lottie.model.animatable.AnimatableTextProperties;
2727
import com.airbnb.lottie.model.content.ShapeGroup;
28+
import com.airbnb.lottie.model.content.TextRangeUnits;
2829
import com.airbnb.lottie.utils.Utils;
2930
import com.airbnb.lottie.value.LottieValueCallback;
3031

@@ -56,6 +57,7 @@ public class TextLayer extends BaseLayer {
5657
private final TextKeyframeAnimation textAnimation;
5758
private final LottieDrawable lottieDrawable;
5859
private final LottieComposition composition;
60+
private TextRangeUnits textRangeUnits = TextRangeUnits.INDEX;
5961
@Nullable
6062
private BaseKeyframeAnimation<Integer, Integer> colorAnimation;
6163
@Nullable
@@ -73,9 +75,17 @@ public class TextLayer extends BaseLayer {
7375
@Nullable
7476
private BaseKeyframeAnimation<Float, Float> trackingCallbackAnimation;
7577
@Nullable
78+
private BaseKeyframeAnimation<Integer, Integer> opacityAnimation;
79+
@Nullable
7680
private BaseKeyframeAnimation<Float, Float> textSizeCallbackAnimation;
7781
@Nullable
7882
private BaseKeyframeAnimation<Typeface, Typeface> typefaceCallbackAnimation;
83+
@Nullable
84+
private BaseKeyframeAnimation<Integer, Integer> textRangeStartAnimation;
85+
@Nullable
86+
private BaseKeyframeAnimation<Integer, Integer> textRangeEndAnimation;
87+
@Nullable
88+
private BaseKeyframeAnimation<Integer, Integer> textRangeOffsetAnimation;
7989

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

8999
AnimatableTextProperties textProperties = layerModel.getTextProperties();
90-
if (textProperties != null && textProperties.color != null) {
91-
colorAnimation = textProperties.color.createAnimation();
100+
if (textProperties != null && textProperties.textStyle != null && textProperties.textStyle.color != null) {
101+
colorAnimation = textProperties.textStyle.color.createAnimation();
92102
colorAnimation.addUpdateListener(this);
93103
addAnimation(colorAnimation);
94104
}
95105

96-
if (textProperties != null && textProperties.stroke != null) {
97-
strokeColorAnimation = textProperties.stroke.createAnimation();
106+
if (textProperties != null && textProperties.textStyle != null && textProperties.textStyle.stroke != null) {
107+
strokeColorAnimation = textProperties.textStyle.stroke.createAnimation();
98108
strokeColorAnimation.addUpdateListener(this);
99109
addAnimation(strokeColorAnimation);
100110
}
101111

102-
if (textProperties != null && textProperties.strokeWidth != null) {
103-
strokeWidthAnimation = textProperties.strokeWidth.createAnimation();
112+
if (textProperties != null && textProperties.textStyle != null && textProperties.textStyle.strokeWidth != null) {
113+
strokeWidthAnimation = textProperties.textStyle.strokeWidth.createAnimation();
104114
strokeWidthAnimation.addUpdateListener(this);
105115
addAnimation(strokeWidthAnimation);
106116
}
107117

108-
if (textProperties != null && textProperties.tracking != null) {
109-
trackingAnimation = textProperties.tracking.createAnimation();
118+
if (textProperties != null && textProperties.textStyle != null && textProperties.textStyle.tracking != null) {
119+
trackingAnimation = textProperties.textStyle.tracking.createAnimation();
110120
trackingAnimation.addUpdateListener(this);
111121
addAnimation(trackingAnimation);
112122
}
123+
124+
if (textProperties != null && textProperties.textStyle != null && textProperties.textStyle.opacity != null) {
125+
opacityAnimation = textProperties.textStyle.opacity.createAnimation();
126+
opacityAnimation.addUpdateListener(this);
127+
addAnimation(opacityAnimation);
128+
}
129+
130+
if (textProperties != null && textProperties.rangeSelector != null && textProperties.rangeSelector.start != null) {
131+
textRangeStartAnimation = textProperties.rangeSelector.start.createAnimation();
132+
textRangeStartAnimation.addUpdateListener(this);
133+
addAnimation(textRangeStartAnimation);
134+
}
135+
136+
if (textProperties != null && textProperties.rangeSelector != null && textProperties.rangeSelector.end != null) {
137+
textRangeEndAnimation = textProperties.rangeSelector.end.createAnimation();
138+
textRangeEndAnimation.addUpdateListener(this);
139+
addAnimation(textRangeEndAnimation);
140+
}
141+
142+
if (textProperties != null && textProperties.rangeSelector != null && textProperties.rangeSelector.offset != null) {
143+
textRangeOffsetAnimation = textProperties.rangeSelector.offset.createAnimation();
144+
textRangeOffsetAnimation.addUpdateListener(this);
145+
addAnimation(textRangeOffsetAnimation);
146+
}
147+
148+
if (textProperties != null && textProperties.rangeSelector != null) {
149+
textRangeUnits = textProperties.rangeSelector.units;
150+
}
113151
}
114152

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

132-
configurePaint(documentData, parentAlpha);
170+
configurePaint(documentData, parentAlpha, 0);
133171

134172
if (lottieDrawable.useTextGlyphs()) {
135-
drawTextWithGlyphs(documentData, parentMatrix, font, canvas);
173+
drawTextWithGlyphs(documentData, parentMatrix, font, canvas, parentAlpha);
136174
} else {
137-
drawTextWithFont(documentData, font, canvas);
175+
drawTextWithFont(documentData, font, canvas, parentAlpha);
138176
}
139177

140178
canvas.restore();
141179
}
142180

143-
private void configurePaint(DocumentData documentData, int parentAlpha) {
144-
if (colorCallbackAnimation != null) {
181+
/**
182+
* Configures the [fillPaint] and [strokePaint] used for drawing based on currently active text ranges.
183+
*
184+
* @param parentAlpha A value from 0 to 255 indicating the alpha of the parented layer.
185+
*/
186+
private void configurePaint(DocumentData documentData, int parentAlpha, int indexInDocument) {
187+
if (colorCallbackAnimation != null) { // dynamic property takes priority
145188
fillPaint.setColor(colorCallbackAnimation.getValue());
146-
} else if (colorAnimation != null) {
189+
} else if (colorAnimation != null && isIndexInRangeSelection(indexInDocument)) {
147190
fillPaint.setColor(colorAnimation.getValue());
148-
} else {
191+
} else { // fall back to the document color
149192
fillPaint.setColor(documentData.color);
150193
}
151194

152195
if (strokeColorCallbackAnimation != null) {
153196
strokePaint.setColor(strokeColorCallbackAnimation.getValue());
154-
} else if (strokeColorAnimation != null) {
197+
} else if (strokeColorAnimation != null && isIndexInRangeSelection(indexInDocument)) {
155198
strokePaint.setColor(strokeColorAnimation.getValue());
156199
} else {
157200
strokePaint.setColor(documentData.strokeColor);
158201
}
159-
int opacity = transform.getOpacity() == null ? 100 : transform.getOpacity().getValue();
160-
int alpha = opacity * 255 / 100 * parentAlpha / 255;
202+
203+
// These opacity values are in the range 0 to 100
204+
int transformOpacity = transform.getOpacity() == null ? 100 : transform.getOpacity().getValue();
205+
int textRangeOpacity = opacityAnimation != null && isIndexInRangeSelection(indexInDocument) ? opacityAnimation.getValue() : 100;
206+
207+
// This alpha value needs to be in the range 0 to 255 to be applied to the Paint instances.
208+
// We map the layer transform's opacity into that range and multiply it by the fractional opacity of the text range and the parent.
209+
int alpha = Math.round((transformOpacity * 255f / 100f)
210+
* (textRangeOpacity / 100f)
211+
* parentAlpha / 255f);
161212
fillPaint.setAlpha(alpha);
162213
strokePaint.setAlpha(alpha);
163214

164215
if (strokeWidthCallbackAnimation != null) {
165216
strokePaint.setStrokeWidth(strokeWidthCallbackAnimation.getValue());
166-
} else if (strokeWidthAnimation != null) {
217+
} else if (strokeWidthAnimation != null && isIndexInRangeSelection(indexInDocument)) {
167218
strokePaint.setStrokeWidth(strokeWidthAnimation.getValue());
168219
} else {
169220
strokePaint.setStrokeWidth(documentData.strokeWidth * Utils.dpScale());
170221
}
171222
}
172223

224+
private boolean isIndexInRangeSelection(int indexInDocument) {
225+
int textLength = textAnimation.getValue().text.length();
226+
if (textRangeStartAnimation != null && textRangeEndAnimation != null) {
227+
// After effects supports reversed text ranges where the start index is greater than the end index.
228+
// For the purposes of determining if the given index is inside of the range, we take the start as the smaller value.
229+
int rangeStart = Math.min(textRangeStartAnimation.getValue(), textRangeEndAnimation.getValue());
230+
int rangeEnd = Math.max(textRangeStartAnimation.getValue(), textRangeEndAnimation.getValue());
231+
232+
if (textRangeOffsetAnimation != null) {
233+
int offset = textRangeOffsetAnimation.getValue();
234+
rangeStart += offset;
235+
rangeEnd += offset;
236+
}
237+
238+
if (textRangeUnits == TextRangeUnits.INDEX) {
239+
return indexInDocument >= rangeStart && indexInDocument < rangeEnd;
240+
} else {
241+
float currentIndexAsPercent = indexInDocument / (float) textLength * 100;
242+
return currentIndexAsPercent >= rangeStart && currentIndexAsPercent < rangeEnd;
243+
}
244+
}
245+
return true;
246+
}
247+
173248
private void drawTextWithGlyphs(
174-
DocumentData documentData, Matrix parentMatrix, Font font, Canvas canvas) {
249+
DocumentData documentData, Matrix parentMatrix, Font font, Canvas canvas, int parentAlpha) {
175250
float textSize;
176251
if (textSizeCallbackAnimation != null) {
177252
textSize = textSizeCallbackAnimation.getValue();
@@ -205,7 +280,7 @@ private void drawTextWithGlyphs(
205280
canvas.save();
206281

207282
if (offsetCanvas(canvas, documentData, lineIndex, line.width)) {
208-
drawGlyphTextLine(line.text, documentData, font, canvas, parentScale, fontScale, tracking);
283+
drawGlyphTextLine(line.text, documentData, font, canvas, parentScale, fontScale, tracking, parentAlpha);
209284
}
210285

211286
canvas.restore();
@@ -214,7 +289,7 @@ private void drawTextWithGlyphs(
214289
}
215290

216291
private void drawGlyphTextLine(String text, DocumentData documentData,
217-
Font font, Canvas canvas, float parentScale, float fontScale, float tracking) {
292+
Font font, Canvas canvas, float parentScale, float fontScale, float tracking, int parentAlpha) {
218293
for (int i = 0; i < text.length(); i++) {
219294
char c = text.charAt(i);
220295
int characterHash = FontCharacter.hashFor(c, font.getFamily(), font.getStyle());
@@ -223,13 +298,13 @@ private void drawGlyphTextLine(String text, DocumentData documentData,
223298
// Something is wrong. Potentially, they didn't export the text as a glyph.
224299
continue;
225300
}
226-
drawCharacterAsGlyph(character, fontScale, documentData, canvas);
301+
drawCharacterAsGlyph(character, fontScale, documentData, canvas, i, parentAlpha);
227302
float tx = (float) character.getWidth() * fontScale * Utils.dpScale() + tracking;
228303
canvas.translate(tx, 0);
229304
}
230305
}
231306

232-
private void drawTextWithFont(DocumentData documentData, Font font, Canvas canvas) {
307+
private void drawTextWithFont(DocumentData documentData, Font font, Canvas canvas, int parentAlpha) {
233308
Typeface typeface = getTypeface(font);
234309
if (typeface == null) {
235310
return;
@@ -263,6 +338,7 @@ private void drawTextWithFont(DocumentData documentData, Font font, Canvas canva
263338
List<String> textLines = getTextLines(text);
264339
int textLineCount = textLines.size();
265340
int lineIndex = -1;
341+
int characterIndexAtStartOfLine = 0;
266342
for (int i = 0; i < textLineCount; i++) {
267343
String textLine = textLines.get(i);
268344
float boxWidth = documentData.boxSize == null ? 0f : documentData.boxSize.x;
@@ -274,9 +350,11 @@ private void drawTextWithFont(DocumentData documentData, Font font, Canvas canva
274350
canvas.save();
275351

276352
if (offsetCanvas(canvas, documentData, lineIndex, line.width)) {
277-
drawFontTextLine(line.text, documentData, canvas, tracking);
353+
drawFontTextLine(line.text, documentData, canvas, tracking, characterIndexAtStartOfLine, parentAlpha);
278354
}
279355

356+
characterIndexAtStartOfLine += line.text.length();
357+
280358
canvas.restore();
281359
}
282360
}
@@ -331,14 +409,23 @@ private List<String> getTextLines(String text) {
331409
return Arrays.asList(textLinesArray);
332410
}
333411

334-
private void drawFontTextLine(String text, DocumentData documentData, Canvas canvas, float tracking) {
412+
/**
413+
* @param characterIndexAtStartOfLine The index within the overall document of the character at the start of the line
414+
* @param parentAlpha
415+
*/
416+
private void drawFontTextLine(String text,
417+
DocumentData documentData,
418+
Canvas canvas,
419+
float tracking,
420+
int characterIndexAtStartOfLine,
421+
int parentAlpha) {
335422
for (int i = 0; i < text.length(); ) {
336423
String charString = codePointToString(text, i);
337-
i += charString.length();
338-
drawCharacterFromFont(charString, documentData, canvas);
424+
drawCharacterFromFont(charString, documentData, canvas, characterIndexAtStartOfLine + i, parentAlpha);
339425
float charWidth = fillPaint.measureText(charString);
340426
float tx = charWidth + tracking;
341427
canvas.translate(tx, 0);
428+
i += charString.length();
342429
}
343430
}
344431

@@ -430,7 +517,10 @@ private void drawCharacterAsGlyph(
430517
FontCharacter character,
431518
float fontScale,
432519
DocumentData documentData,
433-
Canvas canvas) {
520+
Canvas canvas,
521+
int indexInDocument,
522+
int parentAlpha) {
523+
configurePaint(documentData, parentAlpha, indexInDocument);
434524
List<ContentGroup> contentGroups = getContentsForCharacter(character);
435525
for (int j = 0; j < contentGroups.size(); j++) {
436526
Path path = contentGroups.get(j).getPath();
@@ -459,7 +549,8 @@ private void drawGlyph(Path path, Paint paint, Canvas canvas) {
459549
canvas.drawPath(path, paint);
460550
}
461551

462-
private void drawCharacterFromFont(String character, DocumentData documentData, Canvas canvas) {
552+
private void drawCharacterFromFont(String character, DocumentData documentData, Canvas canvas, int indexInDocument, int parentAlpha) {
553+
configurePaint(documentData, parentAlpha, indexInDocument);
463554
if (documentData.strokeOverFill) {
464555
drawCharacter(character, fillPaint, canvas);
465556
drawCharacter(character, strokePaint, canvas);

0 commit comments

Comments
 (0)