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 /** Provide text style linear interpolation for plain text. */
31 class TextInterpolator(layout: Layout, var typefaceCache: TypefaceVariantCache) {
32 /**
33 * Returns base paint used for interpolation.
34 *
35 * Once you modified the style parameters, you have to call reshapeText to recalculate base text
36 * layout.
37 *
38 * Do not bypass the cache and update the typeface or font variation directly.
39 *
40 * @return a paint object
41 */
42 val basePaint = TextPaint(layout.paint)
43
44 /**
45 * Returns target paint used for interpolation.
46 *
47 * Once you modified the style parameters, you have to call reshapeText to recalculate target
48 * text layout.
49 *
50 * Do not bypass the cache and update the typeface or font variation directly.
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
68 get() = end - start
69 }
70
71 /** A class represents text layout of a single run. */
72 private class Run(
73 val glyphIds: IntArray,
74 val baseX: FloatArray, // same length as glyphIds
75 val baseY: FloatArray, // same length as glyphIds
76 val targetX: FloatArray, // same length as glyphIds
77 val targetY: FloatArray, // same length as glyphIds
78 val fontRuns: List<FontRun>,
79 )
80
81 /** A class represents text layout of a single line. */
82 private class Line(val runs: List<Run>)
83
84 private var lines = listOf<Line>()
85 private val fontInterpolator = FontInterpolator(typefaceCache.fontCache)
86
87 // Recycling object for glyph drawing and tweaking.
88 private val tmpPaint = TextPaint()
89 private val tmpPaintForGlyph by lazy { TextPaint() }
90 private val tmpGlyph by lazy { MutablePositionedGlyph() }
91 // Will be extended for the longest font run if needed.
92 private var tmpPositionArray = FloatArray(20)
93
94 /**
95 * The progress position of the interpolation.
96 *
97 * The 0f means the start state, 1f means the end state.
98 */
99 var progress: Float = 0f
100
101 /**
102 * The layout used for drawing text.
103 *
104 * Only non-styled text is supported. Even if the given layout is created from Spanned, the span
105 * information is not used.
106 *
107 * The paint objects used for interpolation are not changed by this method call.
108 *
109 * Note: disabling ligature is strongly recommended if you give extra letter spacing since they
110 * may be disjointed based on letter spacing value and cannot be interpolated. Animator will
111 * throw runtime exception if they cannot be interpolated.
112 */
113 var layout: Layout = layout
114 get() = field
115 set(value) {
116 field = value
117 shapeText(value)
118 }
119
120 var shapedText: String = ""
121 private set
122
123 init {
124 // shapeText needs to be called after all members are initialized.
125 shapeText(layout)
126 }
127
128 /**
129 * Recalculate internal text layout for interpolation.
130 *
131 * Whenever the target paint is modified, call this method to recalculate internal text layout
132 * used for interpolation.
133 */
134 fun onTargetPaintModified() {
135 updatePositionsAndFonts(shapeText(layout, targetPaint), updateBase = false)
136 }
137
138 /**
139 * Recalculate internal text layout for interpolation.
140 *
141 * Whenever the base paint is modified, call this method to recalculate internal text layout
142 * used for interpolation.
143 */
144 fun onBasePaintModified() {
145 updatePositionsAndFonts(shapeText(layout, basePaint), updateBase = true)
146 }
147
148 /**
149 * Rebase the base state to the middle of the interpolation.
150 *
151 * The text interpolator does not calculate all the text position by text shaper due to
152 * performance reasons. Instead, the text interpolator shape the start and end state and
153 * calculate text position of the middle state by linear interpolation. Due to this trick, the
154 * text positions of the middle state is likely different from the text shaper result. So, if
155 * you want to start animation from the middle state, you will see the glyph jumps due to this
156 * trick, i.e. the progress 0.5 of interpolation between weight 400 and 700 is different from
157 * text shape result of weight 550.
158 *
159 * After calling this method, do not call onBasePaintModified() since it reshape the text and
160 * update the base state. As in above notice, the text shaping result at current progress is
161 * different shaped result. By calling onBasePaintModified(), you may see the glyph jump.
162 *
163 * By calling this method, the progress will be reset to 0.
164 *
165 * This API is useful to continue animation from the middle of the state. For example, if you
166 * animate weight from 200 to 400, then if you want to move back to 200 at the half of the
167 * animation, it will look like
168 * <pre> <code>
169 * ```
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 * ```
200 * </code> </pre>
201 */
202 fun rebase() {
203 if (progress == 0f) {
204 return
205 } else if (progress == 1f) {
206 basePaint.set(targetPaint)
207 } else {
208 lerp(basePaint, targetPaint, progress, tmpPaint)
209 basePaint.set(tmpPaint)
210 }
211
212 lines.forEach { line ->
213 line.runs.forEach { run ->
214 for (i in run.baseX.indices) {
215 run.baseX[i] = MathUtils.lerp(run.baseX[i], run.targetX[i], progress)
216 run.baseY[i] = MathUtils.lerp(run.baseY[i], run.targetY[i], progress)
217 }
218 run.fontRuns.forEach { fontRun ->
219 fontRun.baseFont =
220 fontInterpolator.lerp(fontRun.baseFont, fontRun.targetFont, progress)
221 val fvar = FontVariationAxis.toFontVariationSettings(fontRun.baseFont.axes)
222 basePaint.typeface = typefaceCache.getTypefaceForVariant(fvar)
223 }
224 }
225 }
226
227 progress = 0f
228 }
229
230 /**
231 * Draws interpolated text at the given progress.
232 *
233 * @param canvas a canvas.
234 */
235 fun draw(canvas: Canvas) {
236 lerp(basePaint, targetPaint, progress, tmpPaint)
237 lines.forEachIndexed { lineNo, line ->
238 line.runs.forEach { run ->
239 canvas.save()
240 try {
241 // Move to drawing origin.
242 val origin = layout.getDrawOrigin(lineNo)
243 canvas.translate(origin, layout.getLineBaseline(lineNo).toFloat())
244
245 run.fontRuns.forEach { fontRun ->
246 drawFontRun(canvas, run, fontRun, lineNo, tmpPaint)
247 }
248 } finally {
249 canvas.restore()
250 }
251 }
252 }
253 }
254
255 // Shape text with current paint parameters.
256 private fun shapeText(layout: Layout) {
257 val baseLayout = shapeText(layout, basePaint)
258 val targetLayout = shapeText(layout, targetPaint)
259
260 require(baseLayout.size == targetLayout.size) {
261 "The new layout result has different line count."
262 }
263
264 var maxRunLength = 0
265 lines =
266 baseLayout.zip(targetLayout) { baseLine, targetLine ->
267 val runs =
268 baseLine.zip(targetLine) { base, target ->
269 require(base.glyphCount() == target.glyphCount()) {
270 "Inconsistent glyph count at line ${lines.size}"
271 }
272
273 val glyphCount = base.glyphCount()
274
275 // Good to recycle the array if the existing array can hold the new layout
276 // result.
277 val glyphIds =
278 IntArray(glyphCount) {
279 base.getGlyphId(it).also { baseGlyphId ->
280 require(baseGlyphId == target.getGlyphId(it)) {
281 "Inconsistent glyph ID at $it in line ${lines.size}"
282 }
283 }
284 }
285
286 val baseX = FloatArray(glyphCount) { base.getGlyphX(it) }
287 val baseY = FloatArray(glyphCount) { base.getGlyphY(it) }
288 val targetX = FloatArray(glyphCount) { target.getGlyphX(it) }
289 val targetY = FloatArray(glyphCount) { target.getGlyphY(it) }
290
291 // Calculate font runs
292 val fontRun = mutableListOf<FontRun>()
293 if (glyphCount != 0) {
294 var start = 0
295 var baseFont = base.getFont(start)
296 var targetFont = target.getFont(start)
297 require(FontInterpolator.canInterpolate(baseFont, targetFont)) {
298 "Cannot interpolate font at $start ($baseFont vs $targetFont)"
299 }
300
301 for (i in 1 until glyphCount) {
302 val nextBaseFont = base.getFont(i)
303 val nextTargetFont = target.getFont(i)
304
305 if (baseFont !== nextBaseFont) {
306 require(targetFont !== nextTargetFont) {
307 "Base font has changed at $i but target font is unchanged."
308 }
309 // Font transition point. push run and reset context.
310 fontRun.add(FontRun(start, i, baseFont, targetFont))
311 maxRunLength = max(maxRunLength, i - start)
312 baseFont = nextBaseFont
313 targetFont = nextTargetFont
314 start = i
315 require(FontInterpolator.canInterpolate(baseFont, targetFont)) {
316 "Cannot interpolate font at $start" +
317 " ($baseFont vs $targetFont)"
318 }
319 } else { // baseFont === nextBaseFont
320 require(targetFont === nextTargetFont) {
321 "Base font is unchanged at $i but target font has changed."
322 }
323 }
324 }
325 fontRun.add(FontRun(start, glyphCount, baseFont, targetFont))
326 maxRunLength = max(maxRunLength, glyphCount - start)
327 }
328 Run(glyphIds, baseX, baseY, targetX, targetY, fontRun)
329 }
330 Line(runs)
331 }
332
333 // Update float array used for drawing.
334 if (tmpPositionArray.size < maxRunLength * 2) {
335 tmpPositionArray = FloatArray(maxRunLength * 2)
336 }
337 }
338
339 private class MutablePositionedGlyph : TextAnimator.PositionedGlyph() {
340 override var runStart: Int = 0
341 public set
342
343 override var runLength: Int = 0
344 public set
345
346 override var glyphIndex: Int = 0
347 public set
348
349 override lateinit var font: Font
350 public set
351
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
Layoutnull526 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