Merge "Add per glyph filter for tweaking glyph positions for animation" into tm-qpr-dev
This commit is contained in:
committed by
Android (Google) Code Review
commit
e4e9587dc6
@@ -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.
|
* Set text style with an optional animation.
|
||||||
*
|
*
|
||||||
@@ -288,6 +290,7 @@ class AnimatableClockView @JvmOverloads constructor(
|
|||||||
delay = delay,
|
delay = delay,
|
||||||
onAnimationEnd = onAnimationEnd
|
onAnimationEnd = onAnimationEnd
|
||||||
)
|
)
|
||||||
|
textAnimator?.glyphFilter = glyphFilter
|
||||||
} else {
|
} else {
|
||||||
// when the text animator is set, update its start values
|
// when the text animator is set, update its start values
|
||||||
onTextAnimatorInitialized = Runnable {
|
onTextAnimatorInitialized = Runnable {
|
||||||
@@ -301,6 +304,7 @@ class AnimatableClockView @JvmOverloads constructor(
|
|||||||
delay = delay,
|
delay = delay,
|
||||||
onAnimationEnd = onAnimationEnd
|
onAnimationEnd = onAnimationEnd
|
||||||
)
|
)
|
||||||
|
textAnimator?.glyphFilter = glyphFilter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,12 +22,14 @@ import android.animation.TimeInterpolator
|
|||||||
import android.animation.ValueAnimator
|
import android.animation.ValueAnimator
|
||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
import android.graphics.Typeface
|
import android.graphics.Typeface
|
||||||
|
import android.graphics.fonts.Font
|
||||||
import android.text.Layout
|
import android.text.Layout
|
||||||
import android.util.SparseArray
|
import android.util.SparseArray
|
||||||
|
|
||||||
private const val TAG_WGHT = "wght"
|
private const val TAG_WGHT = "wght"
|
||||||
private const val DEFAULT_ANIMATION_DURATION: Long = 300
|
private const val DEFAULT_ANIMATION_DURATION: Long = 300
|
||||||
|
|
||||||
|
typealias GlyphCallback = (TextAnimator.PositionedGlyph, Float) -> Unit
|
||||||
/**
|
/**
|
||||||
* This class provides text animation between two styles.
|
* 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?>()
|
private val typefaceCache = SparseArray<Typeface?>()
|
||||||
|
|
||||||
fun updateLayout(layout: Layout) {
|
fun updateLayout(layout: Layout) {
|
||||||
@@ -84,6 +139,57 @@ class TextAnimator(
|
|||||||
return animator.isRunning
|
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)
|
fun draw(c: Canvas) = textInterpolator.draw(c)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -89,8 +89,11 @@ class TextInterpolator(
|
|||||||
private var lines = listOf<Line>()
|
private var lines = listOf<Line>()
|
||||||
private val fontInterpolator = FontInterpolator()
|
private val fontInterpolator = FontInterpolator()
|
||||||
|
|
||||||
// Recycling object for glyph drawing. Will be extended for the longest font run if needed.
|
// Recycling object for glyph drawing and tweaking.
|
||||||
private val tmpDrawPaint = TextPaint()
|
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)
|
private var tmpPositionArray = FloatArray(20)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -206,8 +209,8 @@ class TextInterpolator(
|
|||||||
} else if (progress == 1f) {
|
} else if (progress == 1f) {
|
||||||
basePaint.set(targetPaint)
|
basePaint.set(targetPaint)
|
||||||
} else {
|
} else {
|
||||||
lerp(basePaint, targetPaint, progress, tmpDrawPaint)
|
lerp(basePaint, targetPaint, progress, tmpPaint)
|
||||||
basePaint.set(tmpDrawPaint)
|
basePaint.set(tmpPaint)
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.forEach { line ->
|
lines.forEach { line ->
|
||||||
@@ -231,7 +234,7 @@ class TextInterpolator(
|
|||||||
* @param canvas a canvas.
|
* @param canvas a canvas.
|
||||||
*/
|
*/
|
||||||
fun draw(canvas: Canvas) {
|
fun draw(canvas: Canvas) {
|
||||||
lerp(basePaint, targetPaint, progress, tmpDrawPaint)
|
lerp(basePaint, targetPaint, progress, tmpPaint)
|
||||||
lines.forEachIndexed { lineNo, line ->
|
lines.forEachIndexed { lineNo, line ->
|
||||||
line.runs.forEach { run ->
|
line.runs.forEach { run ->
|
||||||
canvas.save()
|
canvas.save()
|
||||||
@@ -241,7 +244,7 @@ class TextInterpolator(
|
|||||||
canvas.translate(origin, layout.getLineBaseline(lineNo).toFloat())
|
canvas.translate(origin, layout.getLineBaseline(lineNo).toFloat())
|
||||||
|
|
||||||
run.fontRuns.forEach { fontRun ->
|
run.fontRuns.forEach { fontRun ->
|
||||||
drawFontRun(canvas, run, fontRun, tmpDrawPaint)
|
drawFontRun(canvas, run, fontRun, tmpPaint)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
canvas.restore()
|
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.
|
// Draws single font run.
|
||||||
private fun drawFontRun(c: Canvas, line: Run, run: FontRun, paint: Paint) {
|
private fun drawFontRun(c: Canvas, line: Run, run: FontRun, paint: Paint) {
|
||||||
var arrayIndex = 0
|
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) {
|
for (i in run.start until run.end) {
|
||||||
tmpPositionArray[arrayIndex++] =
|
tmpGlyph.glyphId = line.glyphIds[i]
|
||||||
MathUtils.lerp(line.baseX[i], line.targetX[i], progress)
|
tmpGlyph.x = MathUtils.lerp(line.baseX[i], line.targetX[i], progress)
|
||||||
tmpPositionArray[arrayIndex++] =
|
tmpGlyph.y = MathUtils.lerp(line.baseY[i], line.targetY[i], progress)
|
||||||
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(
|
c.drawGlyphs(
|
||||||
line.glyphIds,
|
line.glyphIds,
|
||||||
run.start,
|
prevStart,
|
||||||
tmpPositionArray,
|
tmpPositionArray,
|
||||||
0,
|
0,
|
||||||
run.length,
|
run.end - prevStart,
|
||||||
fontInterpolator.lerp(run.baseFont, run.targetFont, progress),
|
font,
|
||||||
paint)
|
tmpPaintForGlyph)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updatePositionsAndFonts(
|
private fun updatePositionsAndFonts(
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ package com.android.keyguard
|
|||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
import android.graphics.Typeface
|
import android.graphics.Typeface
|
||||||
import android.graphics.fonts.Font
|
import android.graphics.fonts.Font
|
||||||
import android.graphics.fonts.FontFamily
|
import android.graphics.fonts.FontFamily
|
||||||
@@ -194,6 +195,128 @@ class TextInterpolatorTest : SysuiTestCase() {
|
|||||||
|
|
||||||
assertThat(expected.sameAs(actual)).isTrue()
|
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) =
|
private fun Layout.toBitmap(width: Int, height: Int) =
|
||||||
|
|||||||
Reference in New Issue
Block a user