/* * 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.text.Layout import android.text.TextPaint import android.text.TextShaper import android.util.MathUtils import com.android.internal.graphics.ColorUtils 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 = TextPaint(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 = TextPaint(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 run. */ private class Run( 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 ) /** * A class represents text layout of a single line. */ private class Line( val runs: 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 = TextPaint() 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 the target paint is modified, 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 the base paint is modified, 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 -> line.runs.forEach { run -> for (i in run.baseX.indices) { run.baseX[i] = MathUtils.lerp(run.baseX[i], run.targetX[i], progress) run.baseY[i] = MathUtils.lerp(run.baseY[i], run.targetY[i], progress) } run.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 -> line.runs.forEach { run -> canvas.save() try { // Move to drawing origin. val origin = layout.getDrawOrigin(lineNo) canvas.translate(origin, layout.getLineBaseline(lineNo).toFloat()) run.fontRuns.forEach { fontRun -> drawFontRun(canvas, run, fontRun, 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) { baseLine, targetLine -> val runs = baseLine.zip(targetLine) { 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) } Run(glyphIds, baseX, baseY, targetX, targetY, fontRun) } Line(runs) } // 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: Run, 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, runs -> line.runs.zip(runs) { lineRun, newGlyphs -> require(newGlyphs.glyphCount() == lineRun.glyphIds.size) { "The new layout has different glyph count." } lineRun.fontRuns.forEach { run -> val newFont = newGlyphs.getFont(run.start) for (i in run.start until run.end) { require(newGlyphs.getGlyphId(run.start) == lineRun.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 lineRun.baseX.indices) { lineRun.baseX[i] = newGlyphs.getGlyphX(i) lineRun.baseY[i] = newGlyphs.getGlyphY(i) } } else { for (i in lineRun.baseX.indices) { lineRun.targetX[i] = newGlyphs.getGlyphX(i) lineRun.targetY[i] = newGlyphs.getGlyphY(i) } } } } } // Linear interpolate the paint. private fun lerp(from: Paint, to: Paint, progress: Float, out: Paint) { out.set(from) // Currently only font size & colors are interpolated. // TODO(172943390): Add other interpolation or support custom interpolator. out.textSize = MathUtils.lerp(from.textSize, to.textSize, progress) out.color = ColorUtils.blendARGB(from.color, to.color, progress) } // Shape the text and stores the result to out argument. private fun shapeText( layout: Layout, paint: TextPaint ): 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 val runs = mutableListOf() TextShaper.shapeText(layout.text, lineStart, count, layout.textDirectionHeuristic, paint) { _, _, glyphs, _ -> runs.add(glyphs) } out.add(runs) } return out } } private fun Layout.getDrawOrigin(lineNo: Int) = if (getParagraphDirection(lineNo) == Layout.DIR_LEFT_TO_RIGHT) { getLineLeft(lineNo) } else { getLineRight(lineNo) }