Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 5b68ca9c authored by Seigo Nonaka's avatar Seigo Nonaka
Browse files

Add TextInterpolator for plain text

Bug: 172305675
Test: atest TextInterpolatorTest
Test: atest FontInterpolatorTest
Test: atest KeyguardClockSwitchTest
Test: atest TextAnimatorTest
Change-Id: I704b8f403a05480749589d22b1414e57fe1c3938
parent 5e687f45
Loading
Loading
Loading
Loading
+222 −0
Original line number Diff line number Diff line
/*
 * 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
    }
}
+51 −4
Original line number Diff line number Diff line
@@ -16,12 +16,17 @@

package com.android.keyguard;

import android.annotation.FloatRange;
import android.annotation.IntRange;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.LinearGradient;
import android.graphics.Shader;
import android.util.AttributeSet;
import android.widget.TextClock;

import kotlin.Unit;

/**
 * Displays the time with the hour positioned above the minutes. (ie: 09 above 30 is 9:30)
 * The time's text color is a gradient that changes its colors based on its controller.
@@ -30,6 +35,8 @@ public class GradientTextClock extends TextClock {
    private int[] mGradientColors;
    private float[] mPositions;

    private TextAnimator mTextAnimator = null;

    public GradientTextClock(Context context) {
        this(context, null, 0, 0);
    }
@@ -74,6 +81,24 @@ public class GradientTextClock extends TextClock {
        super.setFormat24Hour(FORMAT_24);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        if (mTextAnimator == null) {
            mTextAnimator = new TextAnimator(getLayout(), () -> {
                invalidate();
                return Unit.INSTANCE;
            });
        } else {
            mTextAnimator.updateLayout(getLayout());
        }
    }

    @Override
    protected void onDraw(Canvas canvas) {
        mTextAnimator.draw(canvas);
    }

    public void setGradientColors(int[] colors) {
        mGradientColors = colors;
        updatePaint();
@@ -83,11 +108,33 @@ public class GradientTextClock extends TextClock {
        mPositions = positions;
    }

    /**
     * Set text style with animation.
     *
     * By passing -1 to weight, the view preserve the current weight.
     * By passing -1 to textSize, the view preserve the current text size.
     *
     * @param weight text weight.
     * @param textSize font size.
     * @param animate true for changing text style with animation, otherwise false.
     */
    public void setTextStyle(
            @IntRange(from = 0, to = 1000) int weight,
            @FloatRange(from = 0) float textSize,
            boolean animate) {
        if (mTextAnimator != null) {
            mTextAnimator.setTextStyle(weight, textSize, animate, -1, null);
        }
    }

    private void updatePaint() {
        getPaint().setShader(
                new LinearGradient(
                        getX(), getY(), getX(), getMeasuredHeight() + getY(),
                        mGradientColors, mPositions, Shader.TileMode.REPEAT));
        Shader shader = new LinearGradient(
                getX(), getY(), getX(), getMeasuredHeight() + getY(), mGradientColors, mPositions,
                Shader.TileMode.REPEAT);
        getPaint().setShader(shader);
        if (mTextAnimator != null) {
            mTextAnimator.setShader(shader);
        }
    }

    private final OnLayoutChangeListener mOnLayoutChangeListener =
+141 −0
Original line number Diff line number Diff line
/*
 * 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
+417 −0

File added.

Preview size limit exceeded, changes collapsed.

+1 −3
Original line number Diff line number Diff line
@@ -71,12 +71,10 @@ public class TimeBasedColorsClockController extends ViewController<GradientTextC
    public void setDarkAmount(float darkAmount) {
        mDarkAmount = darkAmount;

        // TODO: (b/170228350) currently this relayouts throughout the animation;
        //  eventually this should use new Text APIs to animate the variable font weight
        refreshTime(System.currentTimeMillis());

        int weight = (int) MathUtils.lerp(200, 400, 1f - darkAmount);
        mView.setFontVariationSettings("'wght' " + weight);
        mView.setTextStyle(weight, -1 /* unchange text size */, true);
    }

    private int getTimeIndex(long timeInMillis) {
Loading