From 5b68ca9ca9d5b7e9923693164615d91691ba500d Mon Sep 17 00:00:00 2001 From: Seigo Nonaka Date: Mon, 2 Nov 2020 23:34:25 -0800 Subject: [PATCH] Add TextInterpolator for plain text Bug: 172305675 Test: atest TextInterpolatorTest Test: atest FontInterpolatorTest Test: atest KeyguardClockSwitchTest Test: atest TextAnimatorTest Change-Id: I704b8f403a05480749589d22b1414e57fe1c3938 --- .../com/android/keyguard/FontInterpolator.kt | 222 ++++++++++ .../android/keyguard/GradientTextClock.java | 55 ++- .../src/com/android/keyguard/TextAnimator.kt | 141 ++++++ .../com/android/keyguard/TextInterpolator.kt | 417 ++++++++++++++++++ .../TimeBasedColorsClockController.java | 4 +- .../android/keyguard/FontInterpolatorTest.kt | 109 +++++ .../com/android/keyguard/TextAnimatorTest.kt | 111 +++++ .../android/keyguard/TextInterpolatorTest.kt | 143 ++++++ 8 files changed, 1195 insertions(+), 7 deletions(-) create mode 100644 packages/SystemUI/src/com/android/keyguard/FontInterpolator.kt create mode 100644 packages/SystemUI/src/com/android/keyguard/TextAnimator.kt create mode 100644 packages/SystemUI/src/com/android/keyguard/TextInterpolator.kt create mode 100644 packages/SystemUI/tests/src/com/android/keyguard/FontInterpolatorTest.kt create mode 100644 packages/SystemUI/tests/src/com/android/keyguard/TextAnimatorTest.kt create mode 100644 packages/SystemUI/tests/src/com/android/keyguard/TextInterpolatorTest.kt 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 0000000000000..962c0023cf3b9 --- /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 + ) { + constructor(font: Font, axes: List): + this(font.sourceIdentifier, + font.ttcIndex, + axes.toMutableList().apply { sortBy { it.tag } } + ) + + fun set(font: Font, axes: List) { + 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() + private val verFontCache = hashMapOf() + + // 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, + end: Array, + filter: (tag: String, left: Float?, right: Float?) -> Float + ): List { + // 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() + 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() + + // 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 7cf1bd0b3e79b..3942c60a58e97 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 @@ 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 = 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 0000000000000..e4c3dcde3bd4f --- /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: + * + *
+ * 
+ *     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)
+ *         }
+ *     }
+ * 
+ * 
+ */ +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 0000000000000..51148f3c93af6 --- /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 + ) + + private var lines = listOf() + 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 + * + *
+     * 
+     *     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()
+     *     }
+     * 
+     * 
+ * + */ + 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() + 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, + 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 { + val out = mutableListOf() + 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 3cbae0a189377..933d338c07361 100644 --- a/packages/SystemUI/src/com/android/keyguard/TimeBasedColorsClockController.java +++ b/packages/SystemUI/src/com/android/keyguard/TimeBasedColorsClockController.java @@ -71,12 +71,10 @@ public class TimeBasedColorsClockController extends ViewController 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 0000000000000..516d015c0c1cb --- /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 0000000000000..65ffcfc47380a --- /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