• 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.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