• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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