Merge "Add per glyph filter for tweaking glyph positions for animation" into tm-qpr-dev

This commit is contained in:
Hawkwood Glazier
2022-07-22 12:11:39 +00:00
committed by Android (Google) Code Review
4 changed files with 308 additions and 14 deletions

View File

@@ -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
}
}
}

View File

@@ -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)
/**

View File

@@ -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) {
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(

View File

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