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(
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 
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