Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 5fb479d3 authored by Seigo Nonaka's avatar Seigo Nonaka Committed by Hawkwood Glazier
Browse files

Add per glyph filter for tweaking glyph positions for animation

Bug: 199051139
Test: atst TextAnimatorTest TextInterpolatorTest
Change-Id: I721860b78496e9a272b086c246ce0781e3fdc37f
(cherry picked from commit b0080f94)
Merged-In: I721860b78496e9a272b086c246ce0781e3fdc37f
parent dcaadd7f
Loading
Loading
Loading
Loading
+4 −0
Original line number Diff line number Diff line
@@ -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
            }
        }
    }
+106 −0
Original line number Diff line number Diff line
@@ -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<Typeface?>()

    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)

    /**
+75 −14
Original line number Diff line number Diff line
@@ -89,8 +89,11 @@ class TextInterpolator(
    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 @@ 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) {
            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(
+123 −0
Original line number Diff line number Diff line
@@ -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) =