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) =