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:
222
packages/SystemUI/src/com/android/keyguard/FontInterpolator.kt
Normal file
222
packages/SystemUI/src/com/android/keyguard/FontInterpolator.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
141
packages/SystemUI/src/com/android/keyguard/TextAnimator.kt
Normal file
141
packages/SystemUI/src/com/android/keyguard/TextAnimator.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
417
packages/SystemUI/src/com/android/keyguard/TextInterpolator.kt
Normal file
417
packages/SystemUI/src/com/android/keyguard/TextInterpolator.kt
Normal 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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)) }
|
||||
Reference in New Issue
Block a user