diff --git a/packages/SystemUI/src/com/android/keyguard/AnimatableClockView.kt b/packages/SystemUI/src/com/android/keyguard/AnimatableClockView.kt index e22386e783590..b4955d28f84b8 100644 --- a/packages/SystemUI/src/com/android/keyguard/AnimatableClockView.kt +++ b/packages/SystemUI/src/com/android/keyguard/AnimatableClockView.kt @@ -257,6 +257,8 @@ class AnimatableClockView @JvmOverloads constructor( ) } + private val glyphFilter: GlyphCallback? = null // Add text animation tweak here. + /** * Set text style with an optional animation. * @@ -288,6 +290,7 @@ class AnimatableClockView @JvmOverloads constructor( delay = delay, onAnimationEnd = onAnimationEnd ) + textAnimator?.glyphFilter = glyphFilter } else { // when the text animator is set, update its start values onTextAnimatorInitialized = Runnable { @@ -301,6 +304,7 @@ class AnimatableClockView @JvmOverloads constructor( 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 336101528450b..ade89af81bd7c 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.TimeInterpolator 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 @@ class TextAnimator( }) } + 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() fun updateLayout(layout: Layout) { @@ -84,6 +139,57 @@ class TextAnimator( 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 5d5797cbbb3d3..20dbe29efb35f 100644 --- a/packages/SystemUI/src/com/android/keyguard/TextInterpolator.kt +++ b/packages/SystemUI/src/com/android/keyguard/TextInterpolator.kt @@ -89,8 +89,11 @@ class TextInterpolator( private var lines = listOf() 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 @@ class TextInterpolator( } 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 @@ class TextInterpolator( * @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 @@ class TextInterpolator( 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 @@ class TextInterpolator( } } + 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 e42d537596c56..603cf3bcef745 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 @@ package com.android.keyguard 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 @@ class TextInterpolatorTest : SysuiTestCase() { 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) =