Add per glyph filter for tweaking glyph positions for animation
Bug: 199051139
Test: atst TextAnimatorTest TextInterpolatorTest
Change-Id: I721860b78496e9a272b086c246ce0781e3fdc37f
(cherry picked from commit b0080f9402d9e0d61603b1d7b4465920f1f9f84b)
Merged-In: I721860b78496e9a272b086c246ce0781e3fdc37f
diff --git a/packages/SystemUI/src/com/android/keyguard/AnimatableClockView.kt b/packages/SystemUI/src/com/android/keyguard/AnimatableClockView.kt
index e22386e..b4955d2 100644
--- a/packages/SystemUI/src/com/android/keyguard/AnimatableClockView.kt
+++ b/packages/SystemUI/src/com/android/keyguard/AnimatableClockView.kt
@@ -257,6 +257,8 @@
)
}
+ private val glyphFilter: GlyphCallback? = null // Add text animation tweak here.
+
/**
* Set text style with an optional animation.
*
@@ -288,6 +290,7 @@
delay = delay,
onAnimationEnd = onAnimationEnd
)
+ textAnimator?.glyphFilter = glyphFilter
} else {
// when the text animator is set, update its start values
onTextAnimatorInitialized = Runnable {
@@ -301,6 +304,7 @@
delay = delay,
onAnimationEnd = onAnimationEnd
)
+ textAnimator?.glyphFilter = glyphFilter
}
}
}
diff --git a/packages/SystemUI/src/com/android/keyguard/TextAnimator.kt b/packages/SystemUI/src/com/android/keyguard/TextAnimator.kt
index 3361015..ade89af 100644
--- a/packages/SystemUI/src/com/android/keyguard/TextAnimator.kt
+++ b/packages/SystemUI/src/com/android/keyguard/TextAnimator.kt
@@ -22,12 +22,14 @@
import android.animation.ValueAnimator
import android.graphics.Canvas
import android.graphics.Typeface
+import android.graphics.fonts.Font
import android.text.Layout
import android.util.SparseArray
private const val TAG_WGHT = "wght"
private const val DEFAULT_ANIMATION_DURATION: Long = 300
+typealias GlyphCallback = (TextAnimator.PositionedGlyph, Float) -> Unit
/**
* This class provides text animation between two styles.
*
@@ -74,6 +76,59 @@
})
}
+ sealed class PositionedGlyph {
+
+ /**
+ * Mutable X coordinate of the glyph position relative from drawing offset.
+ */
+ var x: Float = 0f
+
+ /**
+ * Mutable Y coordinate of the glyph position relative from the baseline.
+ */
+ var y: Float = 0f
+
+ /**
+ * Mutable text size of the glyph in pixels.
+ */
+ var textSize: Float = 0f
+
+ /**
+ * Mutable color of the glyph.
+ */
+ var color: Int = 0
+
+ /**
+ * Immutable character offset in the text that the current font run start.
+ */
+ abstract var runStart: Int
+ protected set
+
+ /**
+ * Immutable run length of the font run.
+ */
+ abstract var runLength: Int
+ protected set
+
+ /**
+ * Immutable glyph index of the font run.
+ */
+ abstract var glyphIndex: Int
+ protected set
+
+ /**
+ * Immutable font instance for this font run.
+ */
+ abstract var font: Font
+ protected set
+
+ /**
+ * Immutable glyph ID for this glyph.
+ */
+ abstract var glyphId: Int
+ protected set
+ }
+
private val typefaceCache = SparseArray<Typeface?>()
fun updateLayout(layout: Layout) {
@@ -84,6 +139,57 @@
return animator.isRunning
}
+ /**
+ * GlyphFilter applied just before drawing to canvas for tweaking positions and text size.
+ *
+ * This callback is called for each glyphs just before drawing the glyphs. This function will
+ * be called with the intrinsic position, size, color, glyph ID and font instance. You can
+ * mutate the position, size and color for tweaking animations.
+ * Do not keep the reference of passed glyph object. The interpolator reuses that object for
+ * avoiding object allocations.
+ *
+ * Details:
+ * The text is drawn with font run units. The font run is a text segment that draws with the
+ * same font. The {@code runStart} and {@code runLimit} is a range of the font run in the text
+ * that current glyph is in. Once the font run is determined, the system will convert characters
+ * into glyph IDs. The {@code glyphId} is the glyph identifier in the font and
+ * {@code glyphIndex} is the offset of the converted glyph array. Please note that the
+ * {@code glyphIndex} is not a character index, because the character will not be converted to
+ * glyph one-by-one. If there are ligatures including emoji sequence, etc, the glyph ID may be
+ * composed from multiple characters.
+ *
+ * Here is an example of font runs: "fin. 終わり"
+ *
+ * Characters : f i n . _ 終 わ り
+ * Code Points: \u0066 \u0069 \u006E \u002E \u0020 \u7D42 \u308F \u308A
+ * Font Runs : <-- Roboto-Regular.ttf --><-- NotoSans-CJK.otf -->
+ * runStart = 0, runLength = 5 runStart = 5, runLength = 3
+ * Glyph IDs : 194 48 7 8 4367 1039 1002
+ * Glyph Index: 0 1 2 3 0 1 2
+ *
+ * In this example, the "fi" is converted into ligature form, thus the single glyph ID is
+ * assigned for two characters, f and i.
+ *
+ * Example:
+ * ```
+ * private val glyphFilter: GlyphCallback = { glyph, progress ->
+ * val index = glyph.runStart
+ * val i = glyph.glyphIndex
+ * val moveAmount = 1.3f
+ * val sign = (-1 + 2 * ((i + index) % 2))
+ * val turnProgress = if (progress < .5f) progress / 0.5f else (1.0f - progress) / 0.5f
+ *
+ * // You can modify (x, y) coordinates, textSize and color during animation.
+ * glyph.textSize += glyph.textSize * sign * moveAmount * turnProgress
+ * glyph.y += glyph.y * sign * moveAmount * turnProgress
+ * glyph.x += glyph.x * sign * moveAmount * turnProgress
+ * }
+ * ```
+ */
+ var glyphFilter: GlyphCallback?
+ get() = textInterpolator.glyphFilter
+ set(value) { textInterpolator.glyphFilter = value }
+
fun draw(c: Canvas) = textInterpolator.draw(c)
/**
diff --git a/packages/SystemUI/src/com/android/keyguard/TextInterpolator.kt b/packages/SystemUI/src/com/android/keyguard/TextInterpolator.kt
index 5d5797c..20dbe29 100644
--- a/packages/SystemUI/src/com/android/keyguard/TextInterpolator.kt
+++ b/packages/SystemUI/src/com/android/keyguard/TextInterpolator.kt
@@ -89,8 +89,11 @@
private var lines = listOf<Line>()
private val fontInterpolator = FontInterpolator()
- // Recycling object for glyph drawing. Will be extended for the longest font run if needed.
- private val tmpDrawPaint = TextPaint()
+ // Recycling object for glyph drawing and tweaking.
+ private val tmpPaint = TextPaint()
+ private val tmpPaintForGlyph by lazy { TextPaint() }
+ private val tmpGlyph by lazy { MutablePositionedGlyph() }
+ // Will be extended for the longest font run if needed.
private var tmpPositionArray = FloatArray(20)
/**
@@ -206,8 +209,8 @@
} else if (progress == 1f) {
basePaint.set(targetPaint)
} else {
- lerp(basePaint, targetPaint, progress, tmpDrawPaint)
- basePaint.set(tmpDrawPaint)
+ lerp(basePaint, targetPaint, progress, tmpPaint)
+ basePaint.set(tmpPaint)
}
lines.forEach { line ->
@@ -231,7 +234,7 @@
* @param canvas a canvas.
*/
fun draw(canvas: Canvas) {
- lerp(basePaint, targetPaint, progress, tmpDrawPaint)
+ lerp(basePaint, targetPaint, progress, tmpPaint)
lines.forEachIndexed { lineNo, line ->
line.runs.forEach { run ->
canvas.save()
@@ -241,7 +244,7 @@
canvas.translate(origin, layout.getLineBaseline(lineNo).toFloat())
run.fontRuns.forEach { fontRun ->
- drawFontRun(canvas, run, fontRun, tmpDrawPaint)
+ drawFontRun(canvas, run, fontRun, tmpPaint)
}
} finally {
canvas.restore()
@@ -330,24 +333,82 @@
}
}
+ private class MutablePositionedGlyph : TextAnimator.PositionedGlyph() {
+ override var runStart: Int = 0
+ public set
+ override var runLength: Int = 0
+ public set
+ override var glyphIndex: Int = 0
+ public set
+ override lateinit var font: Font
+ public set
+ override var glyphId: Int = 0
+ public set
+ }
+
+ var glyphFilter: GlyphCallback? = null
+
// Draws single font run.
private fun drawFontRun(c: Canvas, line: Run, run: FontRun, paint: Paint) {
var arrayIndex = 0
+ val font = fontInterpolator.lerp(run.baseFont, run.targetFont, progress)
+
+ val glyphFilter = glyphFilter
+ if (glyphFilter == null) {
+ for (i in run.start until run.end) {
+ tmpPositionArray[arrayIndex++] =
+ MathUtils.lerp(line.baseX[i], line.targetX[i], progress)
+ tmpPositionArray[arrayIndex++] =
+ MathUtils.lerp(line.baseY[i], line.targetY[i], progress)
+ }
+ c.drawGlyphs(line.glyphIds, run.start, tmpPositionArray, 0, run.length, font, paint)
+ return
+ }
+
+ tmpGlyph.font = font
+ tmpGlyph.runStart = run.start
+ tmpGlyph.runLength = run.end - run.start
+
+ tmpPaintForGlyph.set(paint)
+ var prevStart = run.start
+
for (i in run.start until run.end) {
- tmpPositionArray[arrayIndex++] =
- MathUtils.lerp(line.baseX[i], line.targetX[i], progress)
- tmpPositionArray[arrayIndex++] =
- MathUtils.lerp(line.baseY[i], line.targetY[i], progress)
+ tmpGlyph.glyphId = line.glyphIds[i]
+ tmpGlyph.x = MathUtils.lerp(line.baseX[i], line.targetX[i], progress)
+ tmpGlyph.y = MathUtils.lerp(line.baseY[i], line.targetY[i], progress)
+ tmpGlyph.textSize = paint.textSize
+ tmpGlyph.color = paint.color
+
+ glyphFilter(tmpGlyph, progress)
+
+ if (tmpGlyph.textSize != paint.textSize || tmpGlyph.color != paint.color) {
+ tmpPaintForGlyph.textSize = tmpGlyph.textSize
+ tmpPaintForGlyph.color = tmpGlyph.color
+
+ c.drawGlyphs(
+ line.glyphIds,
+ prevStart,
+ tmpPositionArray,
+ 0,
+ i - prevStart,
+ font,
+ tmpPaintForGlyph)
+ prevStart = i
+ arrayIndex = 0
+ }
+
+ tmpPositionArray[arrayIndex++] = tmpGlyph.x
+ tmpPositionArray[arrayIndex++] = tmpGlyph.y
}
c.drawGlyphs(
line.glyphIds,
- run.start,
+ prevStart,
tmpPositionArray,
0,
- run.length,
- fontInterpolator.lerp(run.baseFont, run.targetFont, progress),
- paint)
+ run.end - prevStart,
+ font,
+ tmpPaintForGlyph)
}
private fun updatePositionsAndFonts(
diff --git a/packages/SystemUI/tests/src/com/android/keyguard/TextInterpolatorTest.kt b/packages/SystemUI/tests/src/com/android/keyguard/TextInterpolatorTest.kt
index e42d537..603cf3b 100644
--- a/packages/SystemUI/tests/src/com/android/keyguard/TextInterpolatorTest.kt
+++ b/packages/SystemUI/tests/src/com/android/keyguard/TextInterpolatorTest.kt
@@ -18,6 +18,7 @@
import android.graphics.Bitmap
import android.graphics.Canvas
+import android.graphics.Color
import android.graphics.Typeface
import android.graphics.fonts.Font
import android.graphics.fonts.FontFamily
@@ -194,6 +195,128 @@
assertThat(expected.sameAs(actual)).isTrue()
}
+
+ @Test
+ fun testGlyphCallback_Empty() {
+ val layout = makeLayout(BIDI_TEXT, PAINT, TextDirectionHeuristics.RTL)
+
+ val interp = TextInterpolator(layout).apply {
+ glyphFilter = { glyph, progress ->
+ }
+ }
+ interp.basePaint.set(START_PAINT)
+ interp.onBasePaintModified()
+
+ interp.targetPaint.set(END_PAINT)
+ interp.onTargetPaintModified()
+
+ // Just after created TextInterpolator, it should have 0 progress.
+ val actual = interp.toBitmap(BMP_WIDTH, BMP_HEIGHT)
+ val expected = makeLayout(BIDI_TEXT, START_PAINT, TextDirectionHeuristics.RTL)
+ .toBitmap(BMP_WIDTH, BMP_HEIGHT)
+
+ assertThat(expected.sameAs(actual)).isTrue()
+ }
+
+ @Test
+ fun testGlyphCallback_Xcoordinate() {
+ val layout = makeLayout(BIDI_TEXT, PAINT, TextDirectionHeuristics.RTL)
+
+ val interp = TextInterpolator(layout).apply {
+ glyphFilter = { glyph, progress ->
+ glyph.x += 30f
+ }
+ }
+ interp.basePaint.set(START_PAINT)
+ interp.onBasePaintModified()
+
+ interp.targetPaint.set(END_PAINT)
+ interp.onTargetPaintModified()
+
+ // Just after created TextInterpolator, it should have 0 progress.
+ val actual = interp.toBitmap(BMP_WIDTH, BMP_HEIGHT)
+ val expected = makeLayout(BIDI_TEXT, START_PAINT, TextDirectionHeuristics.RTL)
+ .toBitmap(BMP_WIDTH, BMP_HEIGHT)
+
+ // The glyph position was modified by callback, so the bitmap should not be the same.
+ // We cannot modify the result of StaticLayout, so we cannot expect the exact bitmaps.
+ assertThat(expected.sameAs(actual)).isFalse()
+ }
+
+ @Test
+ fun testGlyphCallback_Ycoordinate() {
+ val layout = makeLayout(BIDI_TEXT, PAINT, TextDirectionHeuristics.RTL)
+
+ val interp = TextInterpolator(layout).apply {
+ glyphFilter = { glyph, progress ->
+ glyph.y += 30f
+ }
+ }
+ interp.basePaint.set(START_PAINT)
+ interp.onBasePaintModified()
+
+ interp.targetPaint.set(END_PAINT)
+ interp.onTargetPaintModified()
+
+ // Just after created TextInterpolator, it should have 0 progress.
+ val actual = interp.toBitmap(BMP_WIDTH, BMP_HEIGHT)
+ val expected = makeLayout(BIDI_TEXT, START_PAINT, TextDirectionHeuristics.RTL)
+ .toBitmap(BMP_WIDTH, BMP_HEIGHT)
+
+ // The glyph position was modified by callback, so the bitmap should not be the same.
+ // We cannot modify the result of StaticLayout, so we cannot expect the exact bitmaps.
+ assertThat(expected.sameAs(actual)).isFalse()
+ }
+
+ @Test
+ fun testGlyphCallback_TextSize() {
+ val layout = makeLayout(BIDI_TEXT, PAINT, TextDirectionHeuristics.RTL)
+
+ val interp = TextInterpolator(layout).apply {
+ glyphFilter = { glyph, progress ->
+ glyph.textSize += 10f
+ }
+ }
+ interp.basePaint.set(START_PAINT)
+ interp.onBasePaintModified()
+
+ interp.targetPaint.set(END_PAINT)
+ interp.onTargetPaintModified()
+
+ // Just after created TextInterpolator, it should have 0 progress.
+ val actual = interp.toBitmap(BMP_WIDTH, BMP_HEIGHT)
+ val expected = makeLayout(BIDI_TEXT, START_PAINT, TextDirectionHeuristics.RTL)
+ .toBitmap(BMP_WIDTH, BMP_HEIGHT)
+
+ // The glyph position was modified by callback, so the bitmap should not be the same.
+ // We cannot modify the result of StaticLayout, so we cannot expect the exact bitmaps.
+ assertThat(expected.sameAs(actual)).isFalse()
+ }
+
+ @Test
+ fun testGlyphCallback_Color() {
+ val layout = makeLayout(BIDI_TEXT, PAINT, TextDirectionHeuristics.RTL)
+
+ val interp = TextInterpolator(layout).apply {
+ glyphFilter = { glyph, progress ->
+ glyph.color = Color.RED
+ }
+ }
+ interp.basePaint.set(START_PAINT)
+ interp.onBasePaintModified()
+
+ interp.targetPaint.set(END_PAINT)
+ interp.onTargetPaintModified()
+
+ // Just after created TextInterpolator, it should have 0 progress.
+ val actual = interp.toBitmap(BMP_WIDTH, BMP_HEIGHT)
+ val expected = makeLayout(BIDI_TEXT, START_PAINT, TextDirectionHeuristics.RTL)
+ .toBitmap(BMP_WIDTH, BMP_HEIGHT)
+
+ // The glyph position was modified by callback, so the bitmap should not be the same.
+ // We cannot modify the result of StaticLayout, so we cannot expect the exact bitmaps.
+ assertThat(expected.sameAs(actual)).isFalse()
+ }
}
private fun Layout.toBitmap(width: Int, height: Int) =