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