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