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

Commit b0080f94 authored by Seigo Nonaka's avatar Seigo Nonaka
Browse files

Add per glyph filter for tweaking glyph positions for animation

Bug: 199051139
Test: atst TextAnimatorTest TextInterpolatorTest
Change-Id: I721860b78496e9a272b086c246ce0781e3fdc37f
parent 21fdff5e
Loading
Loading
Loading
Loading
+4 −0
Original line number Diff line number Diff line
@@ -244,6 +244,8 @@ class AnimatableClockView @JvmOverloads constructor(
        )
    }

    private val glyphFilter: GlyphCallback? = null // Add text animation tweak here.

    /**
     * Set text style with an optional animation.
     *
@@ -275,6 +277,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 {
@@ -288,6 +291,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) =