Add TextInterpolator for plain text

Bug: 172305675
Test: atest TextInterpolatorTest
Test: atest FontInterpolatorTest
Test: atest KeyguardClockSwitchTest
Test: atest TextAnimatorTest
Change-Id: I704b8f403a05480749589d22b1414e57fe1c3938
This commit is contained in:
Seigo Nonaka
2020-11-02 23:34:25 -08:00
parent 5e687f4544
commit 5b68ca9ca9
8 changed files with 1195 additions and 7 deletions

View File

@@ -0,0 +1,222 @@
/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.keyguard
import android.graphics.fonts.Font
import android.graphics.fonts.FontVariationAxis
import android.util.MathUtils
private const val TAG_WGHT = "wght"
private const val TAG_ITAL = "ital"
private const val FONT_WEIGHT_MAX = 1000f
private const val FONT_WEIGHT_MIN = 0f
private const val FONT_WEIGHT_ANIMATION_STEP = 10f
private const val FONT_WEIGHT_DEFAULT_VALUE = 400f
private const val FONT_ITALIC_MAX = 1f
private const val FONT_ITALIC_MIN = 0f
private const val FONT_ITALIC_ANIMATION_STEP = 0.1f
private const val FONT_ITALIC_DEFAULT_VALUE = 0f
/**
* Provide interpolation of two fonts by adjusting font variation settings.
*/
class FontInterpolator {
/**
* Cache key for the interpolated font.
*
* This class is mutable for recycling.
*/
private data class InterpKey(var l: Font?, var r: Font?, var progress: Float) {
fun set(l: Font, r: Font, progress: Float) {
this.l = l
this.r = r
this.progress = progress
}
}
/**
* Cache key for the font that has variable font.
*
* This class is mutable for recycling.
*/
private data class VarFontKey(
var sourceId: Int,
var index: Int,
val sortedAxes: MutableList<FontVariationAxis>
) {
constructor(font: Font, axes: List<FontVariationAxis>):
this(font.sourceIdentifier,
font.ttcIndex,
axes.toMutableList().apply { sortBy { it.tag } }
)
fun set(font: Font, axes: List<FontVariationAxis>) {
sourceId = font.sourceIdentifier
index = font.ttcIndex
sortedAxes.clear()
sortedAxes.addAll(axes)
sortedAxes.sortBy { it.tag }
}
}
// Font interpolator has two level caches: one for input and one for font with different
// variation settings. No synchronization is needed since FontInterpolator is not designed to be
// thread-safe and can be used only on UI thread.
private val interpCache = hashMapOf<InterpKey, Font>()
private val verFontCache = hashMapOf<VarFontKey, Font>()
// Mutable keys for recycling.
private val tmpInterpKey = InterpKey(null, null, 0f)
private val tmpVarFontKey = VarFontKey(0, 0, mutableListOf())
/**
* Linear interpolate the font variation settings.
*/
fun lerp(start: Font, end: Font, progress: Float): Font {
if (progress == 0f) {
return start
} else if (progress == 1f) {
return end
}
val startAxes = start.axes ?: EMPTY_AXES
val endAxes = end.axes ?: EMPTY_AXES
if (startAxes.isEmpty() && endAxes.isEmpty()) {
return start
}
// Check we already know the result. This is commonly happens since we draws the different
// text chunks with the same font.
tmpInterpKey.set(start, end, progress)
val cachedFont = interpCache[tmpInterpKey]
if (cachedFont != null) {
return cachedFont
}
// General axes interpolation takes O(N log N), this is came from sorting the axes. Usually
// this doesn't take much time since the variation axes is usually up to 5. If we need to
// support more number of axes, we may want to preprocess the font and store the sorted axes
// and also pre-fill the missing axes value with default value from 'fvar' table.
val newAxes = lerp(startAxes, endAxes) { tag, startValue, endValue ->
when (tag) {
// TODO: Good to parse 'fvar' table for retrieving default value.
TAG_WGHT -> adjustWeight(
MathUtils.lerp(
startValue ?: FONT_WEIGHT_DEFAULT_VALUE,
endValue ?: FONT_WEIGHT_DEFAULT_VALUE,
progress))
TAG_ITAL -> adjustItalic(
MathUtils.lerp(
startValue ?: FONT_ITALIC_DEFAULT_VALUE,
endValue ?: FONT_ITALIC_DEFAULT_VALUE,
progress))
else -> {
require(startValue != null && endValue != null) {
"Unable to interpolate due to unknown default axes value : $tag"
}
MathUtils.lerp(startValue, endValue, progress)
}
}
}
// Check if we already make font for this axes. This is typically happens if the animation
// happens backward.
tmpVarFontKey.set(start, newAxes)
val axesCachedFont = verFontCache[tmpVarFontKey]
if (axesCachedFont != null) {
interpCache[InterpKey(start, end, progress)] = axesCachedFont
return axesCachedFont
}
// This is the first time to make the font for the axes. Build and store it to the cache.
// Font.Builder#build won't throw IOException since creating fonts from existing fonts will
// not do any IO work.
val newFont = Font.Builder(start)
.setFontVariationSettings(newAxes.toTypedArray())
.build()
interpCache[InterpKey(start, end, progress)] = newFont
verFontCache[VarFontKey(start, newAxes)] = newFont
return newFont
}
private fun lerp(
start: Array<FontVariationAxis>,
end: Array<FontVariationAxis>,
filter: (tag: String, left: Float?, right: Float?) -> Float
): List<FontVariationAxis> {
// Safe to modify result of Font#getAxes since it returns cloned object.
start.sortBy { axis -> axis.tag }
end.sortBy { axis -> axis.tag }
val result = mutableListOf<FontVariationAxis>()
var i = 0
var j = 0
while (i < start.size || j < end.size) {
val tagA = if (i < start.size) start[i].tag else null
val tagB = if (j < end.size) end[j].tag else null
val comp = when {
tagA == null -> 1
tagB == null -> -1
else -> tagA.compareTo(tagB)
}
val axis = when {
comp == 0 -> {
val v = filter(tagA!!, start[i++].styleValue, end[j++].styleValue)
FontVariationAxis(tagA, v)
}
comp < 0 -> {
val v = filter(tagA!!, start[i++].styleValue, null)
FontVariationAxis(tagA, v)
}
else -> { // comp > 0
val v = filter(tagB!!, null, end[j++].styleValue)
FontVariationAxis(tagB, v)
}
}
result.add(axis)
}
return result
}
// For the performance reasons, we animate weight with FONT_WEIGHT_ANIMATION_STEP. This helps
// Cache hit ratio in the Skia glyph cache.
private fun adjustWeight(value: Float) =
coerceInWithStep(value, FONT_WEIGHT_MIN, FONT_WEIGHT_MAX, FONT_WEIGHT_ANIMATION_STEP)
// For the performance reasons, we animate italic with FONT_ITALIC_ANIMATION_STEP. This helps
// Cache hit ratio in the Skia glyph cache.
private fun adjustItalic(value: Float) =
coerceInWithStep(value, FONT_ITALIC_MIN, FONT_ITALIC_MAX, FONT_ITALIC_ANIMATION_STEP)
private fun coerceInWithStep(v: Float, min: Float, max: Float, step: Float) =
(v.coerceIn(min, max) / step).toInt() * step
companion object {
private val EMPTY_AXES = arrayOf<FontVariationAxis>()
// Returns true if given two font instance can be interpolated.
fun canInterpolate(start: Font, end: Font) =
start.ttcIndex == end.ttcIndex && start.sourceIdentifier == end.sourceIdentifier
}
}

View File

@@ -16,12 +16,17 @@
package com.android.keyguard;
import android.annotation.FloatRange;
import android.annotation.IntRange;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.LinearGradient;
import android.graphics.Shader;
import android.util.AttributeSet;
import android.widget.TextClock;
import kotlin.Unit;
/**
* Displays the time with the hour positioned above the minutes. (ie: 09 above 30 is 9:30)
* The time's text color is a gradient that changes its colors based on its controller.
@@ -30,6 +35,8 @@ public class GradientTextClock extends TextClock {
private int[] mGradientColors;
private float[] mPositions;
private TextAnimator mTextAnimator = null;
public GradientTextClock(Context context) {
this(context, null, 0, 0);
}
@@ -74,6 +81,24 @@ public class GradientTextClock extends TextClock {
super.setFormat24Hour(FORMAT_24);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
if (mTextAnimator == null) {
mTextAnimator = new TextAnimator(getLayout(), () -> {
invalidate();
return Unit.INSTANCE;
});
} else {
mTextAnimator.updateLayout(getLayout());
}
}
@Override
protected void onDraw(Canvas canvas) {
mTextAnimator.draw(canvas);
}
public void setGradientColors(int[] colors) {
mGradientColors = colors;
updatePaint();
@@ -83,11 +108,33 @@ public class GradientTextClock extends TextClock {
mPositions = positions;
}
/**
* Set text style with animation.
*
* By passing -1 to weight, the view preserve the current weight.
* By passing -1 to textSize, the view preserve the current text size.
*
* @param weight text weight.
* @param textSize font size.
* @param animate true for changing text style with animation, otherwise false.
*/
public void setTextStyle(
@IntRange(from = 0, to = 1000) int weight,
@FloatRange(from = 0) float textSize,
boolean animate) {
if (mTextAnimator != null) {
mTextAnimator.setTextStyle(weight, textSize, animate, -1, null);
}
}
private void updatePaint() {
getPaint().setShader(
new LinearGradient(
getX(), getY(), getX(), getMeasuredHeight() + getY(),
mGradientColors, mPositions, Shader.TileMode.REPEAT));
Shader shader = new LinearGradient(
getX(), getY(), getX(), getMeasuredHeight() + getY(), mGradientColors, mPositions,
Shader.TileMode.REPEAT);
getPaint().setShader(shader);
if (mTextAnimator != null) {
mTextAnimator.setShader(shader);
}
}
private final OnLayoutChangeListener mOnLayoutChangeListener =

View File

@@ -0,0 +1,141 @@
/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.keyguard
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.TimeInterpolator
import android.animation.ValueAnimator
import android.graphics.Canvas
import android.graphics.Shader
import android.text.Layout
private const val TAG_WGHT = "wght"
private const val DEFAULT_ANIMATION_DURATION: Long = 1000
/**
* This class provides text animation between two styles.
*
* Currently this class can provide text style animation for text weight and text size. For example
* the simple view that draws text with animating text size is like as follows:
*
* <pre>
* <code>
* class SimpleTextAnimation : View {
* @JvmOverloads constructor(...)
*
* private val layout: Layout = ... // Text layout, e.g. StaticLayout.
*
* // TextAnimator tells us when needs to be invalidate.
* private val animator = TextAnimator(layout) { invalidate() }
*
* override fun onDraw(canvas: Canvas) = animator.draw(canvas)
*
* // Change the text size with animation.
* fun setTextSize(sizePx: Float, animate: Boolean) {
* animator.setTextStyle(-1 /* unchanged weight */, sizePx, animate)
* }
* }
* </code>
* </pre>
*/
class TextAnimator(layout: Layout, private val invalidateCallback: () -> Unit) {
// Following two members are for mutable for testing purposes.
internal var textInterpolator: TextInterpolator = TextInterpolator(layout)
internal var animator: ValueAnimator = ValueAnimator.ofFloat(1f).apply {
duration = DEFAULT_ANIMATION_DURATION
addUpdateListener {
textInterpolator.progress = it.animatedValue as Float
invalidateCallback()
}
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) = textInterpolator.rebase()
override fun onAnimationCancel(animation: Animator?) = textInterpolator.rebase()
})
}
fun updateLayout(layout: Layout) {
textInterpolator.layout = layout
}
var shader: Shader
get() = textInterpolator.basePaint.shader.also {
require(it === textInterpolator.targetPaint.shader) {
"base and target paint has different shader. Usually shader is not interpolatable."
}
}
set(value) {
textInterpolator.basePaint.shader = value
textInterpolator.targetPaint.shader = value
// Shader doesn't change the text layout, so no need to call onTargetPaintModified or
// onBasePaintModified
}
fun draw(c: Canvas) = textInterpolator.draw(c)
/**
* Set text style with animation.
*
* By passing -1 to weight, the view preserve the current weight.
* By passing -1 to textSize, the view preserve the current text size.
* Bu passing -1 to duration, the default text animation, 1000ms, is used.
* By passing false to animate, the text will be updated without animation.
*
* @param weight an optional text weight.
* @param textSize an optional font size.
* @param animate an optional boolean indicating true for showing style transition as animation,
* false for immediate style transition. True by default.
* @param duration an optional animation duration in milliseconds. This is ignored if animate is
* false.
* @param interpolator an optional time interpolator. If null is passed, last set interpolator
* will be used. This is ignored if animate is false.
*/
fun setTextStyle(
weight: Int = -1,
textSize: Float = -1f,
animate: Boolean = true,
duration: Long = -1L,
interpolator: TimeInterpolator? = null
) {
if (animate) {
animator.cancel()
textInterpolator.rebase()
}
if (textSize >= 0) {
textInterpolator.targetPaint.textSize = textSize
}
if (weight >= 0) {
textInterpolator.targetPaint.fontVariationSettings = "'$TAG_WGHT' $weight"
}
textInterpolator.onTargetPaintModified()
if (animate) {
animator.duration = if (duration == -1L) {
DEFAULT_ANIMATION_DURATION
} else {
duration
}
interpolator?.let { animator.interpolator = it }
animator.start()
} else {
// No animation is requested, thus set base and target state to the same state.
textInterpolator.progress = 1f
textInterpolator.rebase()
}
}
}

View File

@@ -0,0 +1,417 @@
/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.keyguard
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.fonts.Font
import android.graphics.text.PositionedGlyphs
import android.graphics.text.TextRunShaper
import android.text.Layout
import android.util.MathUtils
import java.lang.Math.max
/**
* Provide text style linear interpolation for plain text.
*/
class TextInterpolator(layout: Layout) {
/**
* Returns base paint used for interpolation.
*
* Once you modified the style parameters, you have to call reshapeText to recalculate base text
* layout.
*
* @return a paint object.
*/
val basePaint = Paint(layout.paint)
/**
* Returns target paint used for interpolation.
*
* Once you modified the style parameters, you have to call reshapeText to recalculate target
* text layout.
*
* @return a paint object
*/
val targetPaint = Paint(layout.paint)
/**
* A class represents a single font run.
*
* A font run is a range that will be drawn with the same font.
*/
private data class FontRun(
val start: Int, // inclusive
val end: Int, // exclusive
var baseFont: Font,
var targetFont: Font
) {
val length: Int get() = end - start
}
/**
* A class represents text layout of a single line.
*/
private class Line(
val glyphIds: IntArray,
val baseX: FloatArray, // same length as glyphIds
val baseY: FloatArray, // same length as glyphIds
val targetX: FloatArray, // same length as glyphIds
val targetY: FloatArray, // same length as glyphIds
val fontRuns: List<FontRun>
)
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 = Paint()
private var tmpPositionArray = FloatArray(20)
/**
* The progress position of the interpolation.
*
* The 0f means the start state, 1f means the end state.
*/
var progress: Float = 0f
/**
* The layout used for drawing text.
*
* Only non-styled text is supported. Even if the given layout is created from Spanned, the
* span information is not used.
*
* The paint objects used for interpolation are not changed by this method call.
*
* Note: disabling ligature is strongly recommended if you give extra letter spacing since they
* may be disjointed based on letter spacing value and cannot be interpolated. Animator will
* throw runtime exception if they cannot be interpolated.
*/
var layout: Layout = layout
get() = field
set(value) {
field = value
shapeText(value)
}
init {
// shapeText needs to be called after all members are initialized.
shapeText(layout)
}
/**
* Recalculate internal text layout for interpolation.
*
* Whenever you modifies target paint, you have to call this method to recalculate internal text
* layout used for interpolation.
*/
fun onTargetPaintModified() {
updatePositionsAndFonts(shapeText(layout, targetPaint), updateBase = false)
}
/**
* Recalculate internal text layout for interpolation.
*
* Whenever you modifies base paint, you have to call this method to recalculate internal text
* layout used for interpolation.
*/
fun onBasePaintModified() {
updatePositionsAndFonts(shapeText(layout, basePaint), updateBase = true)
}
/**
* Rebase the base state to the middle of the interpolation.
*
* The text interpolator does not calculate all the text position by text shaper due to
* performance reasons. Instead, the text interpolator shape the start and end state and
* calculate text position of the middle state by linear interpolation. Due to this trick,
* the text positions of the middle state is likely different from the text shaper result.
* So, if you want to start animation from the middle state, you will see the glyph jumps due to
* this trick, i.e. the progress 0.5 of interpolation between weight 400 and 700 is different
* from text shape result of weight 550.
*
* After calling this method, do not call onBasePaintModified() since it reshape the text and
* update the base state. As in above notice, the text shaping result at current progress is
* different shaped result. By calling onBasePaintModified(), you may see the glyph jump.
*
* By calling this method, the progress will be reset to 0.
*
* This API is useful to continue animation from the middle of the state. For example, if you
* animate weight from 200 to 400, then if you want to move back to 200 at the half of the
* animation, it will look like
*
* <pre>
* <code>
* val interp = TextInterpolator(layout)
*
* // Interpolate between weight 200 to 400.
* interp.basePaint.fontVariationSettings = "'wght' 200"
* interp.onBasePaintModified()
* interp.targetPaint.fontVariationSettings = "'wght' 400"
* interp.onTargetPaintModified()
*
* // animate
* val animator = ValueAnimator.ofFloat(1f).apply {
* addUpdaterListener {
* interp.progress = it.animateValue as Float
* }
* }.start()
*
* // Here, assuming you receive some event and want to start new animation from current
* // state.
* OnSomeEvent {
* animator.cancel()
*
* // start another animation from the current state.
* interp.rebase() // Use current state as base state.
* interp.targetPaint.fontVariationSettings = "'wght' 200" // set new target
* interp.onTargetPaintModified() // reshape target
*
* // Here the textInterpolator interpolate from 'wght' from 300 to 200 if the current
* // progress is 0.5
* animator.start()
* }
* </code>
* </pre>
*
*/
fun rebase() {
if (progress == 0f) {
return
} else if (progress == 1f) {
basePaint.set(targetPaint)
} else {
lerp(basePaint, targetPaint, progress, tmpDrawPaint)
basePaint.set(tmpDrawPaint)
}
lines.forEach { line ->
for (i in line.baseX.indices) {
line.baseX[i] = MathUtils.lerp(line.baseX[i], line.targetX[i], progress)
line.baseY[i] = MathUtils.lerp(line.baseY[i], line.targetY[i], progress)
}
line.fontRuns.forEach {
it.baseFont = fontInterpolator.lerp(it.baseFont, it.targetFont, progress)
}
}
progress = 0f
}
/**
* Draws interpolated text at the given progress.
*
* @param canvas a canvas.
*/
fun draw(canvas: Canvas) {
lerp(basePaint, targetPaint, progress, tmpDrawPaint)
lines.forEachIndexed { lineNo, line ->
canvas.save()
try {
// Move to drawing origin.
val origin = layout.getDrawOrigin(lineNo)
canvas.translate(origin, layout.getLineBaseline(lineNo).toFloat())
line.fontRuns.forEach { run ->
drawFontRun(canvas, line, run, tmpDrawPaint)
}
} finally {
canvas.restore()
}
}
}
// Shape text with current paint parameters.
private fun shapeText(layout: Layout) {
val baseLayout = shapeText(layout, basePaint)
val targetLayout = shapeText(layout, targetPaint)
require(baseLayout.size == targetLayout.size) {
"The new layout result has different line count."
}
var maxRunLength = 0
lines = baseLayout.zip(targetLayout) { base, target ->
require(base.glyphCount() == target.glyphCount()) {
"Inconsistent glyph count at line ${lines.size}"
}
val glyphCount = base.glyphCount()
// Good to recycle the array if the existing array can hold the new layout result.
val glyphIds = IntArray(glyphCount) {
base.getGlyphId(it).also { baseGlyphId ->
require(baseGlyphId == target.getGlyphId(it)) {
"Inconsistent glyph ID at $it in line ${lines.size}"
}
}
}
val baseX = FloatArray(glyphCount) { base.getGlyphX(it) }
val baseY = FloatArray(glyphCount) { base.getGlyphY(it) }
val targetX = FloatArray(glyphCount) { target.getGlyphX(it) }
val targetY = FloatArray(glyphCount) { target.getGlyphY(it) }
// Calculate font runs
val fontRun = mutableListOf<FontRun>()
if (glyphCount != 0) {
var start = 0
var baseFont = base.getFont(start)
var targetFont = target.getFont(start)
require(FontInterpolator.canInterpolate(baseFont, targetFont)) {
"Cannot interpolate font at $start ($baseFont vs $targetFont)"
}
for (i in 1 until glyphCount) {
val nextBaseFont = base.getFont(i)
val nextTargetFont = target.getFont(i)
if (baseFont !== nextBaseFont) {
require(targetFont !== nextTargetFont) {
"Base font has changed at $i but target font has not changed."
}
// Font transition point. push run and reset context.
fontRun.add(FontRun(start, i, baseFont, targetFont))
maxRunLength = max(maxRunLength, i - start)
baseFont = nextBaseFont
targetFont = nextTargetFont
start = i
require(FontInterpolator.canInterpolate(baseFont, targetFont)) {
"Cannot interpolate font at $start ($baseFont vs $targetFont)"
}
} else { // baseFont === nextBaseFont
require(targetFont === nextTargetFont) {
"Base font has not changed at $i but target font has changed."
}
}
}
fontRun.add(FontRun(start, glyphCount, baseFont, targetFont))
maxRunLength = max(maxRunLength, glyphCount - start)
}
Line(glyphIds, baseX, baseY, targetX, targetY, fontRun)
}
// Update float array used for drawing.
if (tmpPositionArray.size < maxRunLength * 2) {
tmpPositionArray = FloatArray(maxRunLength * 2)
}
}
// Draws single font run.
private fun drawFontRun(c: Canvas, line: Line, run: FontRun, paint: Paint) {
var arrayIndex = 0
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,
fontInterpolator.lerp(run.baseFont, run.targetFont, progress),
paint)
}
private fun updatePositionsAndFonts(
layoutResult: List<PositionedGlyphs>,
updateBase: Boolean
) {
// Update target positions with newly calculated text layout.
check(layoutResult.size == lines.size) {
"The new layout result has different line count."
}
lines.zip(layoutResult) { line, newGlyphs ->
require(newGlyphs.glyphCount() == line.glyphIds.size) {
"The new layout has different glyph count."
}
line.fontRuns.forEach { run ->
val newFont = newGlyphs.getFont(run.start)
for (i in run.start until run.end) {
require(newGlyphs.getGlyphId(run.start) == line.glyphIds[run.start]) {
"The new layout has different glyph ID at ${run.start}"
}
require(newFont === newGlyphs.getFont(i)) {
"The new layout has different font run." +
" $newFont vs ${newGlyphs.getFont(i)} at $i"
}
}
// The passing base font and target font is already interpolatable, so just check
// new font can be interpolatable with base font.
require(FontInterpolator.canInterpolate(newFont, run.baseFont)) {
"New font cannot be interpolated with existing font. $newFont, ${run.baseFont}"
}
if (updateBase) {
run.baseFont = newFont
} else {
run.targetFont = newFont
}
}
if (updateBase) {
for (i in line.baseX.indices) {
line.baseX[i] = newGlyphs.getGlyphX(i)
line.baseY[i] = newGlyphs.getGlyphY(i)
}
} else {
for (i in line.baseX.indices) {
line.targetX[i] = newGlyphs.getGlyphX(i)
line.targetY[i] = newGlyphs.getGlyphY(i)
}
}
}
}
// Linear interpolate the paint.
private fun lerp(from: Paint, to: Paint, t: Float, out: Paint) {
// Currently only font size is interpolated.
// TODO(172943390): Add other interpolation or support custom interpolator.
out.set(from)
out.textSize = MathUtils.lerp(from.textSize, to.textSize, t)
}
// Shape the text and stores the result to out argument.
private fun shapeText(layout: Layout, paint: Paint): List<PositionedGlyphs> {
val out = mutableListOf<PositionedGlyphs>()
for (lineNo in 0 until layout.lineCount) { // Shape all lines.
val lineStart = layout.getLineStart(lineNo)
val count = layout.getLineEnd(lineNo) - lineStart
out.add(TextRunShaper.shapeTextRun(
layout.text, // Styles are ignored.
lineStart, count, // shape range
lineStart, count, // shape context = shape range.
0f, 0f, // the layout offset. Not changed.
layout.getParagraphDirection(lineNo) == Layout.DIR_RIGHT_TO_LEFT,
paint)) // Use given paint instead of layout's paint for style interpolation.
}
return out
}
}
private fun Layout.getDrawOrigin(lineNo: Int) =
if (getParagraphDirection(lineNo) == Layout.DIR_LEFT_TO_RIGHT) {
getLineLeft(lineNo)
} else {
getLineRight(lineNo)
}

View File

@@ -71,12 +71,10 @@ public class TimeBasedColorsClockController extends ViewController<GradientTextC
public void setDarkAmount(float darkAmount) {
mDarkAmount = darkAmount;
// TODO: (b/170228350) currently this relayouts throughout the animation;
// eventually this should use new Text APIs to animate the variable font weight
refreshTime(System.currentTimeMillis());
int weight = (int) MathUtils.lerp(200, 400, 1f - darkAmount);
mView.setFontVariationSettings("'wght' " + weight);
mView.setTextStyle(weight, -1 /* unchange text size */, true);
}
private int getTimeIndex(long timeInMillis) {

View File

@@ -0,0 +1,109 @@
/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.keyguard
import android.graphics.Paint
import android.graphics.fonts.Font
import android.graphics.fonts.FontVariationAxis
import android.graphics.text.TextRunShaper
import android.testing.AndroidTestingRunner
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidTestingRunner::class)
@SmallTest
class FontInterpolatorTest : SysuiTestCase() {
private val sFont = TextRunShaper.shapeTextRun("A", 0, 1, 0, 1, 0f, 0f, false, Paint())
.getFont(0)
private fun assertSameAxes(expect: Font, actual: Font) {
val expectAxes = expect.axes?.also { it.sortBy { axis -> axis.tag } }
val actualAxes = actual.axes?.also { it.sortBy { axis -> axis.tag } }
assertThat(expectAxes).isEqualTo(actualAxes)
}
private fun assertSameAxes(expectVarSettings: String, actual: Font) {
val expectAxes = FontVariationAxis.fromFontVariationSettings(expectVarSettings)?.also {
it.sortBy { axis -> axis.tag }
}
val actualAxes = actual.axes?.also { it.sortBy { axis -> axis.tag } }
assertThat(expectAxes).isEqualTo(actualAxes)
}
@Test
fun textInterpolation() {
val startFont = Font.Builder(sFont)
.setFontVariationSettings("'wght' 100, 'ital' 0, 'GRAD' 200")
.build()
val endFont = Font.Builder(sFont)
.setFontVariationSettings("'wght' 900, 'ital' 1, 'GRAD' 700")
.build()
val interp = FontInterpolator()
assertSameAxes(startFont, interp.lerp(startFont, endFont, 0f))
assertSameAxes(endFont, interp.lerp(startFont, endFont, 1f))
assertSameAxes("'wght' 500, 'ital' 0.5, 'GRAD' 450", interp.lerp(startFont, endFont, 0.5f))
}
@Test
fun textInterpolation_DefaultValue() {
val startFont = Font.Builder(sFont)
.setFontVariationSettings("'wght' 100")
.build()
val endFont = Font.Builder(sFont)
.setFontVariationSettings("'ital' 1")
.build()
val interp = FontInterpolator()
assertSameAxes("'wght' 250, 'ital' 0.5", interp.lerp(startFont, endFont, 0.5f))
}
@Test
fun testInterpCache() {
val startFont = Font.Builder(sFont)
.setFontVariationSettings("'wght' 100")
.build()
val endFont = Font.Builder(sFont)
.setFontVariationSettings("'ital' 1")
.build()
val interp = FontInterpolator()
val resultFont = interp.lerp(startFont, endFont, 0.5f)
val cachedFont = interp.lerp(startFont, endFont, 0.5f)
assertThat(resultFont).isSameInstanceAs(cachedFont)
}
@Test
fun testAxesCache() {
val startFont = Font.Builder(sFont)
.setFontVariationSettings("'wght' 100")
.build()
val endFont = Font.Builder(sFont)
.setFontVariationSettings("'ital' 1")
.build()
val interp = FontInterpolator()
val resultFont = interp.lerp(startFont, endFont, 0.5f)
val reversedFont = interp.lerp(endFont, startFont, 0.5f)
assertThat(resultFont).isSameInstanceAs(reversedFont)
}
}

View File

@@ -0,0 +1,111 @@
/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.keyguard
import android.animation.ValueAnimator
import android.graphics.Paint
import android.testing.AndroidTestingRunner
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.`when`
import org.mockito.Mockito.inOrder
import org.mockito.Mockito.mock
import org.mockito.Mockito.never
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import kotlin.math.ceil
private val PAINT = TextPaint().apply {
textSize = 32f
}
@RunWith(AndroidTestingRunner::class)
@SmallTest
class TextAnimatorTest : SysuiTestCase() {
private fun makeLayout(text: String, paint: TextPaint): Layout {
val width = ceil(Layout.getDesiredWidth(text, 0, text.length, paint)).toInt()
return StaticLayout.Builder.obtain(text, 0, text.length, paint, width).build()
}
@Test
fun testAnimationStarted() {
val layout = makeLayout("Hello, World", PAINT)
val valueAnimator = mock(ValueAnimator::class.java)
val textInterpolator = mock(TextInterpolator::class.java)
val paint = mock(Paint::class.java)
`when`(textInterpolator.targetPaint).thenReturn(paint)
val textAnimator = TextAnimator(layout, {}).apply {
this.textInterpolator = textInterpolator
this.animator = valueAnimator
}
textAnimator.setTextStyle(
weight = 400,
animate = true
)
// If animation is requested, the base state should be rebased and the target state should
// be updated.
val order = inOrder(textInterpolator)
order.verify(textInterpolator).rebase()
order.verify(textInterpolator).onTargetPaintModified()
// In case of animation, should not shape the base state since the animation should start
// from current state.
verify(textInterpolator, never()).onBasePaintModified()
// Then, animation should be started.
verify(valueAnimator, times(1)).start()
}
@Test
fun testAnimationNotStarted() {
val layout = makeLayout("Hello, World", PAINT)
val valueAnimator = mock(ValueAnimator::class.java)
val textInterpolator = mock(TextInterpolator::class.java)
val paint = mock(Paint::class.java)
`when`(textInterpolator.targetPaint).thenReturn(paint)
val textAnimator = TextAnimator(layout, {}).apply {
this.textInterpolator = textInterpolator
this.animator = valueAnimator
}
textAnimator.setTextStyle(
weight = 400,
animate = false
)
// If animation is not requested, the progress should be 1 which is end of animation and the
// base state is rebased to target state by calling rebase.
val order = inOrder(textInterpolator)
order.verify(textInterpolator).onTargetPaintModified()
order.verify(textInterpolator).progress = 1f
order.verify(textInterpolator).rebase()
// Then, animation start should not be called.
verify(valueAnimator, never()).start()
}
}

View File

@@ -0,0 +1,143 @@
/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.keyguard
import android.graphics.Bitmap
import android.graphics.Canvas
import android.testing.AndroidTestingRunner
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.math.ceil
private const val TEXT = "Hello, World."
private const val BMP_WIDTH = 400
private const val BMP_HEIGHT = 300
private val PAINT = TextPaint().apply {
textSize = 32f
}
private val START_PAINT = TextPaint(PAINT).apply {
fontVariationSettings = "'wght' 400"
}
private val END_PAINT = TextPaint(PAINT).apply {
fontVariationSettings = "'wght' 700"
}
@RunWith(AndroidTestingRunner::class)
@SmallTest
class TextInterpolatorTest : SysuiTestCase() {
private fun makeLayout(text: String, paint: TextPaint): Layout {
val width = ceil(Layout.getDesiredWidth(text, 0, text.length, paint)).toInt()
return StaticLayout.Builder.obtain(text, 0, text.length, paint, width).build()
}
@Test
fun testStartState() {
val layout = makeLayout(TEXT, PAINT)
val interp = TextInterpolator(layout)
interp.basePaint.set(START_PAINT)
interp.onBasePaintModified()
interp.targetPaint.set(END_PAINT)
interp.onTargetPaintModified()
// Just after created TextInterpolator, it should have 0 progress.
assertThat(interp.progress).isEqualTo(0f)
val actual = interp.toBitmap(BMP_WIDTH, BMP_HEIGHT)
val expected = makeLayout(TEXT, START_PAINT).toBitmap(BMP_WIDTH, BMP_HEIGHT)
assertThat(expected.sameAs(actual)).isTrue()
}
@Test
fun testEndState() {
val layout = makeLayout(TEXT, PAINT)
val interp = TextInterpolator(layout)
interp.basePaint.set(START_PAINT)
interp.onBasePaintModified()
interp.targetPaint.set(END_PAINT)
interp.onTargetPaintModified()
interp.progress = 1f
val actual = interp.toBitmap(BMP_WIDTH, BMP_HEIGHT)
val expected = makeLayout(TEXT, END_PAINT).toBitmap(BMP_WIDTH, BMP_HEIGHT)
assertThat(expected.sameAs(actual)).isTrue()
}
@Test
fun testMiddleState() {
val layout = makeLayout(TEXT, PAINT)
val interp = TextInterpolator(layout)
interp.basePaint.set(START_PAINT)
interp.onBasePaintModified()
interp.targetPaint.set(END_PAINT)
interp.onTargetPaintModified()
// We cannot expect exact text layout of the middle position since we don't use text shaping
// result for the middle state for performance reason. Just check it is not equals to start
// end state.
interp.progress = 0.5f
val actual = interp.toBitmap(BMP_WIDTH, BMP_HEIGHT)
assertThat(actual.sameAs(makeLayout(TEXT, START_PAINT).toBitmap(BMP_WIDTH, BMP_HEIGHT)))
.isFalse()
assertThat(actual.sameAs(makeLayout(TEXT, END_PAINT).toBitmap(BMP_WIDTH, BMP_HEIGHT)))
.isFalse()
}
@Test
fun testRebase() {
val layout = makeLayout(TEXT, PAINT)
val interp = TextInterpolator(layout)
interp.basePaint.set(START_PAINT)
interp.onBasePaintModified()
interp.targetPaint.set(END_PAINT)
interp.onTargetPaintModified()
interp.progress = 0.5f
val expected = interp.toBitmap(BMP_WIDTH, BMP_HEIGHT)
// Rebase base state to the current state of progress 0.5.
interp.rebase()
assertThat(interp.progress).isEqualTo(0f)
val actual = interp.toBitmap(BMP_WIDTH, BMP_HEIGHT)
assertThat(expected.sameAs(actual)).isTrue()
}
}
private fun Layout.toBitmap(width: Int, height: Int) =
Bitmap.createBitmap(width, height, Bitmap.Config.ALPHA_8).also { draw(Canvas(it)) }!!
private fun TextInterpolator.toBitmap(width: Int, height: Int) =
Bitmap.createBitmap(width, height, Bitmap.Config.ALPHA_8).also { draw(Canvas(it)) }