Merge "Add per glyph filter for tweaking glyph positions for animation" into tm-qpr-dev am: e4e9587dc6
Original change: https://googleplex-android-review.googlesource.com/c/platform/frameworks/base/+/19358954 Change-Id: I7edb6dbf56e7f719420866e074e1d6a5c74a70be Signed-off-by: Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) =
|
||||
|
||||
Reference in New Issue
Block a user