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