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

Commit f00473f7 authored by Seigo Nonaka's avatar Seigo Nonaka Committed by Android (Google) Code Review
Browse files

Merge "Add TextInterpolator for plain text"

parents cfa12043 5b68ca9c
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