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.keyguard 17 18 import android.graphics.Canvas 19 import android.graphics.Paint 20 import android.graphics.fonts.Font 21 import android.graphics.text.PositionedGlyphs 22 import android.text.Layout 23 import android.text.TextPaint 24 import android.text.TextShaper 25 import android.util.MathUtils 26 import com.android.internal.graphics.ColorUtils 27 import java.lang.Math.max 28 29 /** 30 * Provide text style linear interpolation for plain text. 31 */ 32 class TextInterpolator( 33 layout: Layout 34 ) { 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 * @return a paint object 43 */ 44 val basePaint = TextPaint(layout.paint) 45 46 /** 47 * Returns target paint used for interpolation. 48 * 49 * Once you modified the style parameters, you have to call reshapeText to recalculate target 50 * text layout. 51 * 52 * @return a paint object 53 */ 54 val targetPaint = TextPaint(layout.paint) 55 56 /** 57 * A class represents a single font run. 58 * 59 * A font run is a range that will be drawn with the same font. 60 */ 61 private data class FontRun( 62 val start: Int, // inclusive 63 val end: Int, // exclusive 64 var baseFont: Font, 65 var targetFont: Font 66 ) { 67 val length: Int get() = end - start 68 } 69 70 /** 71 * A class represents text layout of a single run. 72 */ 73 private class Run( 74 val glyphIds: IntArray, 75 val baseX: FloatArray, // same length as glyphIds 76 val baseY: FloatArray, // same length as glyphIds 77 val targetX: FloatArray, // same length as glyphIds 78 val targetY: FloatArray, // same length as glyphIds 79 val fontRuns: List<FontRun> 80 ) 81 82 /** 83 * A class represents text layout of a single line. 84 */ 85 private class Line( 86 val runs: List<Run> 87 ) 88 89 private var lines = listOf<Line>() 90 private val fontInterpolator = FontInterpolator() 91 92 // Recycling object for glyph drawing. Will be extended for the longest font run if needed. 93 private val tmpDrawPaint = TextPaint() 94 private var tmpPositionArray = FloatArray(20) 95 96 /** 97 * The progress position of the interpolation. 98 * 99 * The 0f means the start state, 1f means the end state. 100 */ 101 var progress: Float = 0f 102 103 /** 104 * The layout used for drawing text. 105 * 106 * Only non-styled text is supported. Even if the given layout is created from Spanned, the 107 * span information is not used. 108 * 109 * The paint objects used for interpolation are not changed by this method call. 110 * 111 * Note: disabling ligature is strongly recommended if you give extra letter spacing since they 112 * may be disjointed based on letter spacing value and cannot be interpolated. Animator will 113 * throw runtime exception if they cannot be interpolated. 114 */ 115 var layout: Layout = layout 116 get() = field 117 set(value) { 118 field = value 119 shapeText(value) 120 } 121 122 init { 123 // shapeText needs to be called after all members are initialized. 124 shapeText(layout) 125 } 126 127 /** 128 * Recalculate internal text layout for interpolation. 129 * 130 * Whenever the target paint is modified, call this method to recalculate internal 131 * text layout used for interpolation. 132 */ 133 fun onTargetPaintModified() { 134 updatePositionsAndFonts(shapeText(layout, targetPaint), updateBase = false) 135 } 136 137 /** 138 * Recalculate internal text layout for interpolation. 139 * 140 * Whenever the base paint is modified, call this method to recalculate internal 141 * text layout used for interpolation. 142 */ 143 fun onBasePaintModified() { 144 updatePositionsAndFonts(shapeText(layout, basePaint), updateBase = true) 145 } 146 147 /** 148 * Rebase the base state to the middle of the interpolation. 149 * 150 * The text interpolator does not calculate all the text position by text shaper due to 151 * performance reasons. Instead, the text interpolator shape the start and end state and 152 * calculate text position of the middle state by linear interpolation. Due to this trick, 153 * the text positions of the middle state is likely different from the text shaper result. 154 * So, if you want to start animation from the middle state, you will see the glyph jumps due to 155 * this trick, i.e. the progress 0.5 of interpolation between weight 400 and 700 is different 156 * from text shape result of weight 550. 157 * 158 * After calling this method, do not call onBasePaintModified() since it reshape the text and 159 * update the base state. As in above notice, the text shaping result at current progress is 160 * different shaped result. By calling onBasePaintModified(), you may see the glyph jump. 161 * 162 * By calling this method, the progress will be reset to 0. 163 * 164 * This API is useful to continue animation from the middle of the state. For example, if you 165 * animate weight from 200 to 400, then if you want to move back to 200 at the half of the 166 * animation, it will look like 167 * 168 * <pre> 169 * <code> 170 * val interp = TextInterpolator(layout) 171 * 172 * // Interpolate between weight 200 to 400. 173 * interp.basePaint.fontVariationSettings = "'wght' 200" 174 * interp.onBasePaintModified() 175 * interp.targetPaint.fontVariationSettings = "'wght' 400" 176 * interp.onTargetPaintModified() 177 * 178 * // animate 179 * val animator = ValueAnimator.ofFloat(1f).apply { 180 * addUpdaterListener { 181 * interp.progress = it.animateValue as Float 182 * } 183 * }.start() 184 * 185 * // Here, assuming you receive some event and want to start new animation from current 186 * // state. 187 * OnSomeEvent { 188 * animator.cancel() 189 * 190 * // start another animation from the current state. 191 * interp.rebase() // Use current state as base state. 192 * interp.targetPaint.fontVariationSettings = "'wght' 200" // set new target 193 * interp.onTargetPaintModified() // reshape target 194 * 195 * // Here the textInterpolator interpolate from 'wght' from 300 to 200 if the current 196 * // progress is 0.5 197 * animator.start() 198 * } 199 * </code> 200 * </pre> 201 * 202 */ 203 fun rebase() { 204 if (progress == 0f) { 205 return 206 } else if (progress == 1f) { 207 basePaint.set(targetPaint) 208 } else { 209 lerp(basePaint, targetPaint, progress, tmpDrawPaint) 210 basePaint.set(tmpDrawPaint) 211 } 212 213 lines.forEach { line -> 214 line.runs.forEach { run -> 215 for (i in run.baseX.indices) { 216 run.baseX[i] = MathUtils.lerp(run.baseX[i], run.targetX[i], progress) 217 run.baseY[i] = MathUtils.lerp(run.baseY[i], run.targetY[i], progress) 218 } 219 run.fontRuns.forEach { 220 it.baseFont = fontInterpolator.lerp(it.baseFont, it.targetFont, progress) 221 } 222 } 223 } 224 225 progress = 0f 226 } 227 228 /** 229 * Draws interpolated text at the given progress. 230 * 231 * @param canvas a canvas. 232 */ 233 fun draw(canvas: Canvas) { 234 lerp(basePaint, targetPaint, progress, tmpDrawPaint) 235 lines.forEachIndexed { lineNo, line -> 236 line.runs.forEach { run -> 237 canvas.save() 238 try { 239 // Move to drawing origin. 240 val origin = layout.getDrawOrigin(lineNo) 241 canvas.translate(origin, layout.getLineBaseline(lineNo).toFloat()) 242 243 run.fontRuns.forEach { fontRun -> 244 drawFontRun(canvas, run, fontRun, tmpDrawPaint) 245 } 246 } finally { 247 canvas.restore() 248 } 249 } 250 } 251 } 252 253 // Shape text with current paint parameters. 254 private fun shapeText(layout: Layout) { 255 val baseLayout = shapeText(layout, basePaint) 256 val targetLayout = shapeText(layout, targetPaint) 257 258 require(baseLayout.size == targetLayout.size) { 259 "The new layout result has different line count." 260 } 261 262 var maxRunLength = 0 263 lines = baseLayout.zip(targetLayout) { baseLine, targetLine -> 264 val runs = baseLine.zip(targetLine) { base, target -> 265 266 require(base.glyphCount() == target.glyphCount()) { 267 "Inconsistent glyph count at line ${lines.size}" 268 } 269 270 val glyphCount = base.glyphCount() 271 272 // Good to recycle the array if the existing array can hold the new layout result. 273 val glyphIds = IntArray(glyphCount) { 274 base.getGlyphId(it).also { baseGlyphId -> 275 require(baseGlyphId == target.getGlyphId(it)) { 276 "Inconsistent glyph ID at $it in line ${lines.size}" 277 } 278 } 279 } 280 281 val baseX = FloatArray(glyphCount) { base.getGlyphX(it) } 282 val baseY = FloatArray(glyphCount) { base.getGlyphY(it) } 283 val targetX = FloatArray(glyphCount) { target.getGlyphX(it) } 284 val targetY = FloatArray(glyphCount) { target.getGlyphY(it) } 285 286 // Calculate font runs 287 val fontRun = mutableListOf<FontRun>() 288 if (glyphCount != 0) { 289 var start = 0 290 var baseFont = base.getFont(start) 291 var targetFont = target.getFont(start) 292 require(FontInterpolator.canInterpolate(baseFont, targetFont)) { 293 "Cannot interpolate font at $start ($baseFont vs $targetFont)" 294 } 295 296 for (i in 1 until glyphCount) { 297 val nextBaseFont = base.getFont(i) 298 val nextTargetFont = target.getFont(i) 299 300 if (baseFont !== nextBaseFont) { 301 require(targetFont !== nextTargetFont) { 302 "Base font has changed at $i but target font has not changed." 303 } 304 // Font transition point. push run and reset context. 305 fontRun.add(FontRun(start, i, baseFont, targetFont)) 306 maxRunLength = max(maxRunLength, i - start) 307 baseFont = nextBaseFont 308 targetFont = nextTargetFont 309 start = i 310 require(FontInterpolator.canInterpolate(baseFont, targetFont)) { 311 "Cannot interpolate font at $start ($baseFont vs $targetFont)" 312 } 313 } else { // baseFont === nextBaseFont 314 require(targetFont === nextTargetFont) { 315 "Base font has not changed at $i but target font has changed." 316 } 317 } 318 } 319 fontRun.add(FontRun(start, glyphCount, baseFont, targetFont)) 320 maxRunLength = max(maxRunLength, glyphCount - start) 321 } 322 Run(glyphIds, baseX, baseY, targetX, targetY, fontRun) 323 } 324 Line(runs) 325 } 326 327 // Update float array used for drawing. 328 if (tmpPositionArray.size < maxRunLength * 2) { 329 tmpPositionArray = FloatArray(maxRunLength * 2) 330 } 331 } 332 333 // Draws single font run. 334 private fun drawFontRun(c: Canvas, line: Run, run: FontRun, paint: Paint) { 335 var arrayIndex = 0 336 for (i in run.start until run.end) { 337 tmpPositionArray[arrayIndex++] = 338 MathUtils.lerp(line.baseX[i], line.targetX[i], progress) 339 tmpPositionArray[arrayIndex++] = 340 MathUtils.lerp(line.baseY[i], line.targetY[i], progress) 341 } 342 343 c.drawGlyphs( 344 line.glyphIds, 345 run.start, 346 tmpPositionArray, 347 0, 348 run.length, 349 fontInterpolator.lerp(run.baseFont, run.targetFont, progress), 350 paint) 351 } 352 353 private fun updatePositionsAndFonts( 354 layoutResult: List<List<PositionedGlyphs>>, 355 updateBase: Boolean 356 ) { 357 // Update target positions with newly calculated text layout. 358 check(layoutResult.size == lines.size) { 359 "The new layout result has different line count." 360 } 361 362 lines.zip(layoutResult) { line, runs -> 363 line.runs.zip(runs) { lineRun, newGlyphs -> 364 require(newGlyphs.glyphCount() == lineRun.glyphIds.size) { 365 "The new layout has different glyph count." 366 } 367 368 lineRun.fontRuns.forEach { run -> 369 val newFont = newGlyphs.getFont(run.start) 370 for (i in run.start until run.end) { 371 require(newGlyphs.getGlyphId(run.start) == lineRun.glyphIds[run.start]) { 372 "The new layout has different glyph ID at ${run.start}" 373 } 374 require(newFont === newGlyphs.getFont(i)) { 375 "The new layout has different font run." + 376 " $newFont vs ${newGlyphs.getFont(i)} at $i" 377 } 378 } 379 380 // The passing base font and target font is already interpolatable, so just 381 // check new font can be interpolatable with base font. 382 require(FontInterpolator.canInterpolate(newFont, run.baseFont)) { 383 "New font cannot be interpolated with existing font. $newFont," + 384 " ${run.baseFont}" 385 } 386 387 if (updateBase) { 388 run.baseFont = newFont 389 } else { 390 run.targetFont = newFont 391 } 392 } 393 394 if (updateBase) { 395 for (i in lineRun.baseX.indices) { 396 lineRun.baseX[i] = newGlyphs.getGlyphX(i) 397 lineRun.baseY[i] = newGlyphs.getGlyphY(i) 398 } 399 } else { 400 for (i in lineRun.baseX.indices) { 401 lineRun.targetX[i] = newGlyphs.getGlyphX(i) 402 lineRun.targetY[i] = newGlyphs.getGlyphY(i) 403 } 404 } 405 } 406 } 407 } 408 409 // Linear interpolate the paint. 410 private fun lerp(from: Paint, to: Paint, progress: Float, out: Paint) { 411 out.set(from) 412 413 // Currently only font size & colors are interpolated. 414 // TODO(172943390): Add other interpolation or support custom interpolator. 415 out.textSize = MathUtils.lerp(from.textSize, to.textSize, progress) 416 out.color = ColorUtils.blendARGB(from.color, to.color, progress) 417 } 418 419 // Shape the text and stores the result to out argument. 420 private fun shapeText( 421 layout: Layout, 422 paint: TextPaint 423 ): List<List<PositionedGlyphs>> { 424 val out = mutableListOf<List<PositionedGlyphs>>() 425 for (lineNo in 0 until layout.lineCount) { // Shape all lines. 426 val lineStart = layout.getLineStart(lineNo) 427 val count = layout.getLineEnd(lineNo) - lineStart 428 val runs = mutableListOf<PositionedGlyphs>() 429 TextShaper.shapeText(layout.text, lineStart, count, layout.textDirectionHeuristic, 430 paint) { _, _, glyphs, _ -> 431 runs.add(glyphs) 432 } 433 out.add(runs) 434 } 435 return out 436 } 437 } 438 439 private fun Layout.getDrawOrigin(lineNo: Int) = 440 if (getParagraphDirection(lineNo) == Layout.DIR_LEFT_TO_RIGHT) { 441 getLineLeft(lineNo) 442 } else { 443 getLineRight(lineNo) 444 } 445