1 /* 2 * Copyright (C) 2020 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.android.systemui.animation 17 18 import android.graphics.Canvas 19 import android.graphics.Paint 20 import android.graphics.fonts.Font 21 import android.graphics.fonts.FontVariationAxis 22 import android.graphics.text.PositionedGlyphs 23 import android.text.Layout 24 import android.text.TextPaint 25 import android.text.TextShaper 26 import android.util.MathUtils 27 import com.android.internal.graphics.ColorUtils 28 import java.lang.Math.max 29 30 /** Provide text style linear interpolation for plain text. */ 31 class TextInterpolator( 32 layout: Layout, 33 var typefaceCache: TypefaceVariantCache, 34 numberOfAnimationSteps: Int? = null, 35 ) { 36 /** 37 * Returns base paint used for interpolation. 38 * 39 * Once you modified the style parameters, you have to call reshapeText to recalculate base text 40 * layout. 41 * 42 * Do not bypass the cache and update the typeface or font variation directly. 43 * 44 * @return a paint object 45 */ 46 val basePaint = TextPaint(layout.paint) 47 48 /** 49 * Returns target paint used for interpolation. 50 * 51 * Once you modified the style parameters, you have to call reshapeText to recalculate target 52 * text layout. 53 * 54 * Do not bypass the cache and update the typeface or font variation directly. 55 * 56 * @return a paint object 57 */ 58 val targetPaint = TextPaint(layout.paint) 59 60 /** 61 * A class represents a single font run. 62 * 63 * A font run is a range that will be drawn with the same font. 64 */ 65 private data class FontRun( 66 val start: Int, // inclusive 67 val end: Int, // exclusive 68 var baseFont: Font, 69 var targetFont: Font 70 ) { 71 val length: Int 72 get() = end - start 73 } 74 75 /** A class represents text layout of a single run. */ 76 private class Run( 77 val glyphIds: IntArray, 78 val baseX: FloatArray, // same length as glyphIds 79 val baseY: FloatArray, // same length as glyphIds 80 val targetX: FloatArray, // same length as glyphIds 81 val targetY: FloatArray, // same length as glyphIds 82 val fontRuns: List<FontRun> 83 ) 84 85 /** A class represents text layout of a single line. */ 86 private class Line(val runs: List<Run>) 87 88 private var lines = listOf<Line>() 89 private val fontInterpolator = FontInterpolator(numberOfAnimationSteps) 90 91 // Recycling object for glyph drawing and tweaking. 92 private val tmpPaint = TextPaint() 93 private val tmpPaintForGlyph by lazy { TextPaint() } 94 private val tmpGlyph by lazy { MutablePositionedGlyph() } 95 // Will be extended for the longest font run if needed. 96 private var tmpPositionArray = FloatArray(20) 97 98 /** 99 * The progress position of the interpolation. 100 * 101 * The 0f means the start state, 1f means the end state. 102 */ 103 var progress: Float = 0f 104 105 /** 106 * The layout used for drawing text. 107 * 108 * Only non-styled text is supported. Even if the given layout is created from Spanned, the span 109 * information is not used. 110 * 111 * The paint objects used for interpolation are not changed by this method call. 112 * 113 * Note: disabling ligature is strongly recommended if you give extra letter spacing since they 114 * may be disjointed based on letter spacing value and cannot be interpolated. Animator will 115 * throw runtime exception if they cannot be interpolated. 116 */ 117 var layout: Layout = layout 118 get() = field 119 set(value) { 120 field = value 121 shapeText(value) 122 } 123 124 var shapedText: String = "" 125 private set 126 127 init { 128 // shapeText needs to be called after all members are initialized. 129 shapeText(layout) 130 } 131 132 /** 133 * Recalculate internal text layout for interpolation. 134 * 135 * Whenever the target paint is modified, call this method to recalculate internal text layout 136 * used for interpolation. 137 */ 138 fun onTargetPaintModified() { 139 updatePositionsAndFonts(shapeText(layout, targetPaint), updateBase = false) 140 } 141 142 /** 143 * Recalculate internal text layout for interpolation. 144 * 145 * Whenever the base paint is modified, call this method to recalculate internal text layout 146 * used for interpolation. 147 */ 148 fun onBasePaintModified() { 149 updatePositionsAndFonts(shapeText(layout, basePaint), updateBase = true) 150 } 151 152 /** 153 * Rebase the base state to the middle of the interpolation. 154 * 155 * The text interpolator does not calculate all the text position by text shaper due to 156 * performance reasons. Instead, the text interpolator shape the start and end state and 157 * calculate text position of the middle state by linear interpolation. Due to this trick, the 158 * text positions of the middle state is likely different from the text shaper result. So, if 159 * you want to start animation from the middle state, you will see the glyph jumps due to this 160 * trick, i.e. the progress 0.5 of interpolation between weight 400 and 700 is different from 161 * text shape result of weight 550. 162 * 163 * After calling this method, do not call onBasePaintModified() since it reshape the text and 164 * update the base state. As in above notice, the text shaping result at current progress is 165 * different shaped result. By calling onBasePaintModified(), you may see the glyph jump. 166 * 167 * By calling this method, the progress will be reset to 0. 168 * 169 * This API is useful to continue animation from the middle of the state. For example, if you 170 * animate weight from 200 to 400, then if you want to move back to 200 at the half of the 171 * animation, it will look like 172 * <pre> <code> 173 * ``` 174 * val interp = TextInterpolator(layout) 175 * 176 * // Interpolate between weight 200 to 400. 177 * interp.basePaint.fontVariationSettings = "'wght' 200" 178 * interp.onBasePaintModified() 179 * interp.targetPaint.fontVariationSettings = "'wght' 400" 180 * interp.onTargetPaintModified() 181 * 182 * // animate 183 * val animator = ValueAnimator.ofFloat(1f).apply { 184 * addUpdaterListener { 185 * interp.progress = it.animateValue as Float 186 * } 187 * }.start() 188 * 189 * // Here, assuming you receive some event and want to start new animation from current 190 * // state. 191 * OnSomeEvent { 192 * animator.cancel() 193 * 194 * // start another animation from the current state. 195 * interp.rebase() // Use current state as base state. 196 * interp.targetPaint.fontVariationSettings = "'wght' 200" // set new target 197 * interp.onTargetPaintModified() // reshape target 198 * 199 * // Here the textInterpolator interpolate from 'wght' from 300 to 200 if the current 200 * // progress is 0.5 201 * animator.start() 202 * } 203 * ``` 204 * </code> </pre> 205 */ 206 fun rebase() { 207 if (progress == 0f) { 208 return 209 } else if (progress == 1f) { 210 basePaint.set(targetPaint) 211 } else { 212 lerp(basePaint, targetPaint, progress, tmpPaint) 213 basePaint.set(tmpPaint) 214 } 215 216 lines.forEach { line -> 217 line.runs.forEach { run -> 218 for (i in run.baseX.indices) { 219 run.baseX[i] = MathUtils.lerp(run.baseX[i], run.targetX[i], progress) 220 run.baseY[i] = MathUtils.lerp(run.baseY[i], run.targetY[i], progress) 221 } 222 run.fontRuns.forEach { fontRun -> 223 fontRun.baseFont = 224 fontInterpolator.lerp(fontRun.baseFont, fontRun.targetFont, progress) 225 val fvar = FontVariationAxis.toFontVariationSettings(fontRun.baseFont.axes) 226 basePaint.typeface = typefaceCache.getTypefaceForVariant(fvar) 227 } 228 } 229 } 230 231 progress = 0f 232 } 233 234 /** 235 * Draws interpolated text at the given progress. 236 * 237 * @param canvas a canvas. 238 */ 239 fun draw(canvas: Canvas) { 240 lerp(basePaint, targetPaint, progress, tmpPaint) 241 lines.forEachIndexed { lineNo, line -> 242 line.runs.forEach { run -> 243 canvas.save() 244 try { 245 // Move to drawing origin. 246 val origin = layout.getDrawOrigin(lineNo) 247 canvas.translate(origin, layout.getLineBaseline(lineNo).toFloat()) 248 249 run.fontRuns.forEach { fontRun -> 250 drawFontRun(canvas, run, fontRun, lineNo, tmpPaint) 251 } 252 } finally { 253 canvas.restore() 254 } 255 } 256 } 257 } 258 259 // Shape text with current paint parameters. 260 private fun shapeText(layout: Layout) { 261 val baseLayout = shapeText(layout, basePaint) 262 val targetLayout = shapeText(layout, targetPaint) 263 264 require(baseLayout.size == targetLayout.size) { 265 "The new layout result has different line count." 266 } 267 268 var maxRunLength = 0 269 lines = 270 baseLayout.zip(targetLayout) { baseLine, targetLine -> 271 val runs = 272 baseLine.zip(targetLine) { base, target -> 273 require(base.glyphCount() == target.glyphCount()) { 274 "Inconsistent glyph count at line ${lines.size}" 275 } 276 277 val glyphCount = base.glyphCount() 278 279 // Good to recycle the array if the existing array can hold the new layout 280 // result. 281 val glyphIds = 282 IntArray(glyphCount) { 283 base.getGlyphId(it).also { baseGlyphId -> 284 require(baseGlyphId == target.getGlyphId(it)) { 285 "Inconsistent glyph ID at $it in line ${lines.size}" 286 } 287 } 288 } 289 290 val baseX = FloatArray(glyphCount) { base.getGlyphX(it) } 291 val baseY = FloatArray(glyphCount) { base.getGlyphY(it) } 292 val targetX = FloatArray(glyphCount) { target.getGlyphX(it) } 293 val targetY = FloatArray(glyphCount) { target.getGlyphY(it) } 294 295 // Calculate font runs 296 val fontRun = mutableListOf<FontRun>() 297 if (glyphCount != 0) { 298 var start = 0 299 var baseFont = base.getFont(start) 300 var targetFont = target.getFont(start) 301 require(FontInterpolator.canInterpolate(baseFont, targetFont)) { 302 "Cannot interpolate font at $start ($baseFont vs $targetFont)" 303 } 304 305 for (i in 1 until glyphCount) { 306 val nextBaseFont = base.getFont(i) 307 val nextTargetFont = target.getFont(i) 308 309 if (baseFont !== nextBaseFont) { 310 require(targetFont !== nextTargetFont) { 311 "Base font has changed at $i but target font is unchanged." 312 } 313 // Font transition point. push run and reset context. 314 fontRun.add(FontRun(start, i, baseFont, targetFont)) 315 maxRunLength = max(maxRunLength, i - start) 316 baseFont = nextBaseFont 317 targetFont = nextTargetFont 318 start = i 319 require(FontInterpolator.canInterpolate(baseFont, targetFont)) { 320 "Cannot interpolate font at $start" + 321 " ($baseFont vs $targetFont)" 322 } 323 } else { // baseFont === nextBaseFont 324 require(targetFont === nextTargetFont) { 325 "Base font is unchanged at $i but target font has changed." 326 } 327 } 328 } 329 fontRun.add(FontRun(start, glyphCount, baseFont, targetFont)) 330 maxRunLength = max(maxRunLength, glyphCount - start) 331 } 332 Run(glyphIds, baseX, baseY, targetX, targetY, fontRun) 333 } 334 Line(runs) 335 } 336 337 // Update float array used for drawing. 338 if (tmpPositionArray.size < maxRunLength * 2) { 339 tmpPositionArray = FloatArray(maxRunLength * 2) 340 } 341 } 342 343 private class MutablePositionedGlyph : TextAnimator.PositionedGlyph() { 344 override var runStart: Int = 0 345 public set 346 override var runLength: Int = 0 347 public set 348 override var glyphIndex: Int = 0 349 public set 350 override lateinit var font: Font 351 public set 352 override var glyphId: Int = 0 353 public set 354 } 355 356 var glyphFilter: GlyphCallback? = null 357 358 // Draws single font run. 359 private fun drawFontRun(c: Canvas, line: Run, run: FontRun, lineNo: Int, paint: Paint) { 360 var arrayIndex = 0 361 val font = fontInterpolator.lerp(run.baseFont, run.targetFont, progress) 362 363 val glyphFilter = glyphFilter 364 if (glyphFilter == null) { 365 for (i in run.start until run.end) { 366 tmpPositionArray[arrayIndex++] = 367 MathUtils.lerp(line.baseX[i], line.targetX[i], progress) 368 tmpPositionArray[arrayIndex++] = 369 MathUtils.lerp(line.baseY[i], line.targetY[i], progress) 370 } 371 c.drawGlyphs(line.glyphIds, run.start, tmpPositionArray, 0, run.length, font, paint) 372 return 373 } 374 375 tmpGlyph.font = font 376 tmpGlyph.runStart = run.start 377 tmpGlyph.runLength = run.end - run.start 378 tmpGlyph.lineNo = lineNo 379 380 tmpPaintForGlyph.set(paint) 381 var prevStart = run.start 382 383 for (i in run.start until run.end) { 384 tmpGlyph.glyphIndex = i 385 tmpGlyph.glyphId = line.glyphIds[i] 386 tmpGlyph.x = MathUtils.lerp(line.baseX[i], line.targetX[i], progress) 387 tmpGlyph.y = MathUtils.lerp(line.baseY[i], line.targetY[i], progress) 388 tmpGlyph.textSize = paint.textSize 389 tmpGlyph.color = paint.color 390 391 glyphFilter(tmpGlyph, progress) 392 393 if (tmpGlyph.textSize != paint.textSize || tmpGlyph.color != paint.color) { 394 tmpPaintForGlyph.textSize = tmpGlyph.textSize 395 tmpPaintForGlyph.color = tmpGlyph.color 396 397 c.drawGlyphs( 398 line.glyphIds, 399 prevStart, 400 tmpPositionArray, 401 0, 402 i - prevStart, 403 font, 404 tmpPaintForGlyph 405 ) 406 prevStart = i 407 arrayIndex = 0 408 } 409 410 tmpPositionArray[arrayIndex++] = tmpGlyph.x 411 tmpPositionArray[arrayIndex++] = tmpGlyph.y 412 } 413 414 c.drawGlyphs( 415 line.glyphIds, 416 prevStart, 417 tmpPositionArray, 418 0, 419 run.end - prevStart, 420 font, 421 tmpPaintForGlyph 422 ) 423 } 424 425 private fun updatePositionsAndFonts( 426 layoutResult: List<List<PositionedGlyphs>>, 427 updateBase: Boolean 428 ) { 429 // Update target positions with newly calculated text layout. 430 check(layoutResult.size == lines.size) { "The new layout result has different line count." } 431 432 lines.zip(layoutResult) { line, runs -> 433 line.runs.zip(runs) { lineRun, newGlyphs -> 434 require(newGlyphs.glyphCount() == lineRun.glyphIds.size) { 435 "The new layout has different glyph count." 436 } 437 438 lineRun.fontRuns.forEach { run -> 439 val newFont = newGlyphs.getFont(run.start) 440 for (i in run.start until run.end) { 441 require(newGlyphs.getGlyphId(run.start) == lineRun.glyphIds[run.start]) { 442 "The new layout has different glyph ID at ${run.start}" 443 } 444 require(newFont === newGlyphs.getFont(i)) { 445 "The new layout has different font run." + 446 " $newFont vs ${newGlyphs.getFont(i)} at $i" 447 } 448 } 449 450 // The passing base font and target font is already interpolatable, so just 451 // check new font can be interpolatable with base font. 452 require(FontInterpolator.canInterpolate(newFont, run.baseFont)) { 453 "New font cannot be interpolated with existing font. $newFont," + 454 " ${run.baseFont}" 455 } 456 457 if (updateBase) { 458 run.baseFont = newFont 459 } else { 460 run.targetFont = newFont 461 } 462 } 463 464 if (updateBase) { 465 for (i in lineRun.baseX.indices) { 466 lineRun.baseX[i] = newGlyphs.getGlyphX(i) 467 lineRun.baseY[i] = newGlyphs.getGlyphY(i) 468 } 469 } else { 470 for (i in lineRun.baseX.indices) { 471 lineRun.targetX[i] = newGlyphs.getGlyphX(i) 472 lineRun.targetY[i] = newGlyphs.getGlyphY(i) 473 } 474 } 475 } 476 } 477 } 478 479 // Linear interpolate the paint. 480 private fun lerp(from: Paint, to: Paint, progress: Float, out: Paint) { 481 out.set(from) 482 483 // Currently only font size & colors are interpolated. 484 // TODO(172943390): Add other interpolation or support custom interpolator. 485 out.textSize = MathUtils.lerp(from.textSize, to.textSize, progress) 486 out.color = ColorUtils.blendARGB(from.color, to.color, progress) 487 out.strokeWidth = MathUtils.lerp(from.strokeWidth, to.strokeWidth, progress) 488 } 489 490 // Shape the text and stores the result to out argument. 491 private fun shapeText(layout: Layout, paint: TextPaint): List<List<PositionedGlyphs>> { 492 var text = StringBuilder() 493 val out = mutableListOf<List<PositionedGlyphs>>() 494 for (lineNo in 0 until layout.lineCount) { // Shape all lines. 495 val lineStart = layout.getLineStart(lineNo) 496 val lineEnd = layout.getLineEnd(lineNo) 497 var count = lineEnd - lineStart 498 // Do not render the last character in the line if it's a newline and unprintable 499 val last = lineStart + count - 1 500 if (last > lineStart && last < layout.text.length && layout.text[last] == '\n') { 501 count-- 502 } 503 504 val runs = mutableListOf<PositionedGlyphs>() 505 TextShaper.shapeText( 506 layout.text, 507 lineStart, 508 count, 509 layout.textDirectionHeuristic, 510 paint 511 ) { _, _, glyphs, _ -> 512 runs.add(glyphs) 513 } 514 out.add(runs) 515 516 if (lineNo > 0) { 517 text.append("\n") 518 } 519 text.append(layout.text.substring(lineStart, lineEnd)) 520 } 521 shapedText = text.toString() 522 return out 523 } 524 } 525 526 private fun Layout.getDrawOrigin(lineNo: Int) = 527 if (getParagraphDirection(lineNo) == Layout.DIR_LEFT_TO_RIGHT) { 528 getLineLeft(lineNo) 529 } else { 530 getLineRight(lineNo) 531 } 532