| /* |
| * 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<FontRun> |
| ) |
| |
| /** |
| * A class represents text layout of a single line. |
| */ |
| private class Line( |
| val runs: List<Run> |
| ) |
| |
| private var lines = listOf<Line>() |
| private val fontInterpolator = FontInterpolator() |
| |
| // Recycling object for glyph drawing and tweaking. |
| private val tmpPaint = TextPaint() |
| private val tmpPaintForGlyph by lazy { TextPaint() } |
| private val tmpGlyph by lazy { MutablePositionedGlyph() } |
| // Will be extended for the longest font run if needed. |
| 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 |
| * |
| * <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, tmpPaint) |
| basePaint.set(tmpPaint) |
| } |
| |
| 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, tmpPaint) |
| 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, tmpPaint) |
| } |
| } 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<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) |
| } |
| Run(glyphIds, baseX, baseY, targetX, targetY, fontRun) |
| } |
| Line(runs) |
| } |
| |
| // Update float array used for drawing. |
| if (tmpPositionArray.size < maxRunLength * 2) { |
| tmpPositionArray = FloatArray(maxRunLength * 2) |
| } |
| } |
| |
| private class MutablePositionedGlyph : TextAnimator.PositionedGlyph() { |
| override var runStart: Int = 0 |
| public set |
| override var runLength: Int = 0 |
| public set |
| override var glyphIndex: Int = 0 |
| public set |
| override lateinit var font: Font |
| public set |
| override var glyphId: Int = 0 |
| public set |
| } |
| |
| var glyphFilter: GlyphCallback? = null |
| |
| // Draws single font run. |
| private fun drawFontRun(c: Canvas, line: Run, run: FontRun, paint: Paint) { |
| var arrayIndex = 0 |
| val font = fontInterpolator.lerp(run.baseFont, run.targetFont, progress) |
| |
| val glyphFilter = glyphFilter |
| if (glyphFilter == null) { |
| 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, font, paint) |
| return |
| } |
| |
| tmpGlyph.font = font |
| tmpGlyph.runStart = run.start |
| tmpGlyph.runLength = run.end - run.start |
| |
| tmpPaintForGlyph.set(paint) |
| var prevStart = run.start |
| |
| for (i in run.start until run.end) { |
| tmpGlyph.glyphId = line.glyphIds[i] |
| tmpGlyph.x = MathUtils.lerp(line.baseX[i], line.targetX[i], progress) |
| tmpGlyph.y = MathUtils.lerp(line.baseY[i], line.targetY[i], progress) |
| tmpGlyph.textSize = paint.textSize |
| tmpGlyph.color = paint.color |
| |
| glyphFilter(tmpGlyph, progress) |
| |
| if (tmpGlyph.textSize != paint.textSize || tmpGlyph.color != paint.color) { |
| tmpPaintForGlyph.textSize = tmpGlyph.textSize |
| tmpPaintForGlyph.color = tmpGlyph.color |
| |
| c.drawGlyphs( |
| line.glyphIds, |
| prevStart, |
| tmpPositionArray, |
| 0, |
| i - prevStart, |
| font, |
| tmpPaintForGlyph) |
| prevStart = i |
| arrayIndex = 0 |
| } |
| |
| tmpPositionArray[arrayIndex++] = tmpGlyph.x |
| tmpPositionArray[arrayIndex++] = tmpGlyph.y |
| } |
| |
| c.drawGlyphs( |
| line.glyphIds, |
| prevStart, |
| tmpPositionArray, |
| 0, |
| run.end - prevStart, |
| font, |
| tmpPaintForGlyph) |
| } |
| |
| private fun updatePositionsAndFonts( |
| layoutResult: List<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, 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<List<PositionedGlyphs>> { |
| val out = mutableListOf<List<PositionedGlyphs>>() |
| for (lineNo in 0 until layout.lineCount) { // Shape all lines. |
| val lineStart = layout.getLineStart(lineNo) |
| val count = layout.getLineEnd(lineNo) - lineStart |
| val runs = mutableListOf<PositionedGlyphs>() |
| 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) |
| } |