blob: ff64c789112850934e5151c57fd72d0c737b8501 [file] [log] [blame]
/*
* 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.systemui.animation
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)
}