Merge "Add TextInterpolator for plain text"
diff --git a/packages/SystemUI/src/com/android/keyguard/FontInterpolator.kt b/packages/SystemUI/src/com/android/keyguard/FontInterpolator.kt
new file mode 100644
index 0000000..962c002
--- /dev/null
+++ b/packages/SystemUI/src/com/android/keyguard/FontInterpolator.kt
@@ -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
+ }
+}
diff --git a/packages/SystemUI/src/com/android/keyguard/GradientTextClock.java b/packages/SystemUI/src/com/android/keyguard/GradientTextClock.java
index 7cf1bd0..3942c60 100644
--- a/packages/SystemUI/src/com/android/keyguard/GradientTextClock.java
+++ b/packages/SystemUI/src/com/android/keyguard/GradientTextClock.java
@@ -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 @@
private int[] mGradientColors;
private float[] mPositions;
+ private TextAnimator mTextAnimator = null;
+
public GradientTextClock(Context context) {
this(context, null, 0, 0);
}
@@ -74,6 +81,24 @@
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 @@
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 =
diff --git a/packages/SystemUI/src/com/android/keyguard/TextAnimator.kt b/packages/SystemUI/src/com/android/keyguard/TextAnimator.kt
new file mode 100644
index 0000000..e4c3dcd
--- /dev/null
+++ b/packages/SystemUI/src/com/android/keyguard/TextAnimator.kt
@@ -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()
+ }
+ }
+}
\ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/keyguard/TextInterpolator.kt b/packages/SystemUI/src/com/android/keyguard/TextInterpolator.kt
new file mode 100644
index 0000000..51148f3
--- /dev/null
+++ b/packages/SystemUI/src/com/android/keyguard/TextInterpolator.kt
@@ -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)
+ }
\ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/keyguard/TimeBasedColorsClockController.java b/packages/SystemUI/src/com/android/keyguard/TimeBasedColorsClockController.java
index 3cbae0a..933d338 100644
--- a/packages/SystemUI/src/com/android/keyguard/TimeBasedColorsClockController.java
+++ b/packages/SystemUI/src/com/android/keyguard/TimeBasedColorsClockController.java
@@ -71,12 +71,10 @@
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) {
diff --git a/packages/SystemUI/tests/src/com/android/keyguard/FontInterpolatorTest.kt b/packages/SystemUI/tests/src/com/android/keyguard/FontInterpolatorTest.kt
new file mode 100644
index 0000000..95fa3b9
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/keyguard/FontInterpolatorTest.kt
@@ -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)
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/keyguard/TextAnimatorTest.kt b/packages/SystemUI/tests/src/com/android/keyguard/TextAnimatorTest.kt
new file mode 100644
index 0000000..516d015
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/keyguard/TextAnimatorTest.kt
@@ -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()
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/keyguard/TextInterpolatorTest.kt b/packages/SystemUI/tests/src/com/android/keyguard/TextInterpolatorTest.kt
new file mode 100644
index 0000000..65ffcfc
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/keyguard/TextInterpolatorTest.kt
@@ -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)) }
\ No newline at end of file