1 /*
2  * 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.keyguard
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 /**
30  * Provide text style linear interpolation for plain text.
31  */
32 class TextInterpolator(
33     layout: Layout
34 ) {
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      * @return a paint object
43      */
44     val basePaint = TextPaint(layout.paint)
45 
46     /**
47      * Returns target paint used for interpolation.
48      *
49      * Once you modified the style parameters, you have to call reshapeText to recalculate target
50      * text layout.
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 get() = end - start
68     }
69 
70     /**
71      * A class represents text layout of a single run.
72      */
73     private class Run(
74         val glyphIds: IntArray,
75         val baseX: FloatArray, // same length as glyphIds
76         val baseY: FloatArray, // same length as glyphIds
77         val targetX: FloatArray, // same length as glyphIds
78         val targetY: FloatArray, // same length as glyphIds
79         val fontRuns: List<FontRun>
80     )
81 
82     /**
83      * A class represents text layout of a single line.
84      */
85     private class Line(
86         val runs: List<Run>
87     )
88 
89     private var lines = listOf<Line>()
90     private val fontInterpolator = FontInterpolator()
91 
92     // Recycling object for glyph drawing. Will be extended for the longest font run if needed.
93     private val tmpDrawPaint = TextPaint()
94     private var tmpPositionArray = FloatArray(20)
95 
96     /**
97      * The progress position of the interpolation.
98      *
99      * The 0f means the start state, 1f means the end state.
100      */
101     var progress: Float = 0f
102 
103     /**
104      * The layout used for drawing text.
105      *
106      * Only non-styled text is supported. Even if the given layout is created from Spanned, the
107      * span information is not used.
108      *
109      * The paint objects used for interpolation are not changed by this method call.
110      *
111      * Note: disabling ligature is strongly recommended if you give extra letter spacing since they
112      * may be disjointed based on letter spacing value and cannot be interpolated. Animator will
113      * throw runtime exception if they cannot be interpolated.
114      */
115     var layout: Layout = layout
116         get() = field
117         set(value) {
118             field = value
119             shapeText(value)
120         }
121 
122     init {
123         // shapeText needs to be called after all members are initialized.
124         shapeText(layout)
125     }
126 
127     /**
128      * Recalculate internal text layout for interpolation.
129      *
130      * Whenever the target paint is modified, call this method to recalculate internal
131      * text layout used for interpolation.
132      */
133     fun onTargetPaintModified() {
134         updatePositionsAndFonts(shapeText(layout, targetPaint), updateBase = false)
135     }
136 
137     /**
138      * Recalculate internal text layout for interpolation.
139      *
140      * Whenever the base paint is modified, call this method to recalculate internal
141      * text layout used for interpolation.
142      */
143     fun onBasePaintModified() {
144         updatePositionsAndFonts(shapeText(layout, basePaint), updateBase = true)
145     }
146 
147     /**
148      * Rebase the base state to the middle of the interpolation.
149      *
150      * The text interpolator does not calculate all the text position by text shaper due to
151      * performance reasons. Instead, the text interpolator shape the start and end state and
152      * calculate text position of the middle state by linear interpolation. Due to this trick,
153      * the text positions of the middle state is likely different from the text shaper result.
154      * So, if you want to start animation from the middle state, you will see the glyph jumps due to
155      * this trick, i.e. the progress 0.5 of interpolation between weight 400 and 700 is different
156      * from text shape result of weight 550.
157      *
158      * After calling this method, do not call onBasePaintModified() since it reshape the text and
159      * update the base state. As in above notice, the text shaping result at current progress is
160      * different shaped result. By calling onBasePaintModified(), you may see the glyph jump.
161      *
162      * By calling this method, the progress will be reset to 0.
163      *
164      * This API is useful to continue animation from the middle of the state. For example, if you
165      * animate weight from 200 to 400, then if you want to move back to 200 at the half of the
166      * animation, it will look like
167      *
168      * <pre>
169      * <code>
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      * </code>
200      * </pre>
201      *
202      */
203     fun rebase() {
204         if (progress == 0f) {
205             return
206         } else if (progress == 1f) {
207             basePaint.set(targetPaint)
208         } else {
209             lerp(basePaint, targetPaint, progress, tmpDrawPaint)
210             basePaint.set(tmpDrawPaint)
211         }
212 
213         lines.forEach { line ->
214             line.runs.forEach { run ->
215                 for (i in run.baseX.indices) {
216                     run.baseX[i] = MathUtils.lerp(run.baseX[i], run.targetX[i], progress)
217                     run.baseY[i] = MathUtils.lerp(run.baseY[i], run.targetY[i], progress)
218                 }
219                 run.fontRuns.forEach {
220                     it.baseFont = fontInterpolator.lerp(it.baseFont, it.targetFont, progress)
221                 }
222             }
223         }
224 
225         progress = 0f
226     }
227 
228     /**
229      * Draws interpolated text at the given progress.
230      *
231      * @param canvas a canvas.
232      */
233     fun draw(canvas: Canvas) {
234         lerp(basePaint, targetPaint, progress, tmpDrawPaint)
235         lines.forEachIndexed { lineNo, line ->
236             line.runs.forEach { run ->
237                 canvas.save()
238                 try {
239                     // Move to drawing origin.
240                     val origin = layout.getDrawOrigin(lineNo)
241                     canvas.translate(origin, layout.getLineBaseline(lineNo).toFloat())
242 
243                     run.fontRuns.forEach { fontRun ->
244                         drawFontRun(canvas, run, fontRun, tmpDrawPaint)
245                     }
246                 } finally {
247                     canvas.restore()
248                 }
249             }
250         }
251     }
252 
253     // Shape text with current paint parameters.
254     private fun shapeText(layout: Layout) {
255         val baseLayout = shapeText(layout, basePaint)
256         val targetLayout = shapeText(layout, targetPaint)
257 
258         require(baseLayout.size == targetLayout.size) {
259             "The new layout result has different line count."
260         }
261 
262         var maxRunLength = 0
263         lines = baseLayout.zip(targetLayout) { baseLine, targetLine ->
264             val runs = baseLine.zip(targetLine) { base, target ->
265 
266                 require(base.glyphCount() == target.glyphCount()) {
267                     "Inconsistent glyph count at line ${lines.size}"
268                 }
269 
270                 val glyphCount = base.glyphCount()
271 
272                 // Good to recycle the array if the existing array can hold the new layout result.
273                 val glyphIds = IntArray(glyphCount) {
274                     base.getGlyphId(it).also { baseGlyphId ->
275                         require(baseGlyphId == target.getGlyphId(it)) {
276                             "Inconsistent glyph ID at $it in line ${lines.size}"
277                         }
278                     }
279                 }
280 
281                 val baseX = FloatArray(glyphCount) { base.getGlyphX(it) }
282                 val baseY = FloatArray(glyphCount) { base.getGlyphY(it) }
283                 val targetX = FloatArray(glyphCount) { target.getGlyphX(it) }
284                 val targetY = FloatArray(glyphCount) { target.getGlyphY(it) }
285 
286                 // Calculate font runs
287                 val fontRun = mutableListOf<FontRun>()
288                 if (glyphCount != 0) {
289                     var start = 0
290                     var baseFont = base.getFont(start)
291                     var targetFont = target.getFont(start)
292                     require(FontInterpolator.canInterpolate(baseFont, targetFont)) {
293                         "Cannot interpolate font at $start ($baseFont vs $targetFont)"
294                     }
295 
296                     for (i in 1 until glyphCount) {
297                         val nextBaseFont = base.getFont(i)
298                         val nextTargetFont = target.getFont(i)
299 
300                         if (baseFont !== nextBaseFont) {
301                             require(targetFont !== nextTargetFont) {
302                                 "Base font has changed at $i but target font has not changed."
303                             }
304                             // Font transition point. push run and reset context.
305                             fontRun.add(FontRun(start, i, baseFont, targetFont))
306                             maxRunLength = max(maxRunLength, i - start)
307                             baseFont = nextBaseFont
308                             targetFont = nextTargetFont
309                             start = i
310                             require(FontInterpolator.canInterpolate(baseFont, targetFont)) {
311                                 "Cannot interpolate font at $start ($baseFont vs $targetFont)"
312                             }
313                         } else { // baseFont === nextBaseFont
314                             require(targetFont === nextTargetFont) {
315                                 "Base font has not changed at $i but target font has changed."
316                             }
317                         }
318                     }
319                     fontRun.add(FontRun(start, glyphCount, baseFont, targetFont))
320                     maxRunLength = max(maxRunLength, glyphCount - start)
321                 }
322                 Run(glyphIds, baseX, baseY, targetX, targetY, fontRun)
323             }
324             Line(runs)
325         }
326 
327         // Update float array used for drawing.
328         if (tmpPositionArray.size < maxRunLength * 2) {
329             tmpPositionArray = FloatArray(maxRunLength * 2)
330         }
331     }
332 
333     // Draws single font run.
334     private fun drawFontRun(c: Canvas, line: Run, run: FontRun, paint: Paint) {
335         var arrayIndex = 0
336         for (i in run.start until run.end) {
337             tmpPositionArray[arrayIndex++] =
338                     MathUtils.lerp(line.baseX[i], line.targetX[i], progress)
339             tmpPositionArray[arrayIndex++] =
340                     MathUtils.lerp(line.baseY[i], line.targetY[i], progress)
341         }
342 
343         c.drawGlyphs(
344                 line.glyphIds,
345                 run.start,
346                 tmpPositionArray,
347                 0,
348                 run.length,
349                 fontInterpolator.lerp(run.baseFont, run.targetFont, progress),
350                 paint)
351     }
352 
353     private fun updatePositionsAndFonts(
354         layoutResult: List<List<PositionedGlyphs>>,
355         updateBase: Boolean
356     ) {
357         // Update target positions with newly calculated text layout.
358         check(layoutResult.size == lines.size) {
359             "The new layout result has different line count."
360         }
361 
362         lines.zip(layoutResult) { line, runs ->
363             line.runs.zip(runs) { lineRun, newGlyphs ->
364                 require(newGlyphs.glyphCount() == lineRun.glyphIds.size) {
365                     "The new layout has different glyph count."
366                 }
367 
368                 lineRun.fontRuns.forEach { run ->
369                     val newFont = newGlyphs.getFont(run.start)
370                     for (i in run.start until run.end) {
371                         require(newGlyphs.getGlyphId(run.start) == lineRun.glyphIds[run.start]) {
372                             "The new layout has different glyph ID at ${run.start}"
373                         }
374                         require(newFont === newGlyphs.getFont(i)) {
375                             "The new layout has different font run." +
376                                     " $newFont vs ${newGlyphs.getFont(i)} at $i"
377                         }
378                     }
379 
380                     // The passing base font and target font is already interpolatable, so just
381                     // check new font can be interpolatable with base font.
382                     require(FontInterpolator.canInterpolate(newFont, run.baseFont)) {
383                         "New font cannot be interpolated with existing font. $newFont," +
384                                 " ${run.baseFont}"
385                     }
386 
387                     if (updateBase) {
388                         run.baseFont = newFont
389                     } else {
390                         run.targetFont = newFont
391                     }
392                 }
393 
394                 if (updateBase) {
395                     for (i in lineRun.baseX.indices) {
396                         lineRun.baseX[i] = newGlyphs.getGlyphX(i)
397                         lineRun.baseY[i] = newGlyphs.getGlyphY(i)
398                     }
399                 } else {
400                     for (i in lineRun.baseX.indices) {
401                         lineRun.targetX[i] = newGlyphs.getGlyphX(i)
402                         lineRun.targetY[i] = newGlyphs.getGlyphY(i)
403                     }
404                 }
405             }
406         }
407     }
408 
409     // Linear interpolate the paint.
410     private fun lerp(from: Paint, to: Paint, progress: Float, out: Paint) {
411         out.set(from)
412 
413         // Currently only font size & colors are interpolated.
414         // TODO(172943390): Add other interpolation or support custom interpolator.
415         out.textSize = MathUtils.lerp(from.textSize, to.textSize, progress)
416         out.color = ColorUtils.blendARGB(from.color, to.color, progress)
417     }
418 
419     // Shape the text and stores the result to out argument.
420     private fun shapeText(
421         layout: Layout,
422         paint: TextPaint
423     ): List<List<PositionedGlyphs>> {
424         val out = mutableListOf<List<PositionedGlyphs>>()
425         for (lineNo in 0 until layout.lineCount) { // Shape all lines.
426             val lineStart = layout.getLineStart(lineNo)
427             val count = layout.getLineEnd(lineNo) - lineStart
428             val runs = mutableListOf<PositionedGlyphs>()
429             TextShaper.shapeText(layout.text, lineStart, count, layout.textDirectionHeuristic,
430                     paint) { _, _, glyphs, _ ->
431                 runs.add(glyphs)
432             }
433             out.add(runs)
434         }
435         return out
436     }
437 }
438 
439 private fun Layout.getDrawOrigin(lineNo: Int) =
440         if (getParagraphDirection(lineNo) == Layout.DIR_LEFT_TO_RIGHT) {
441             getLineLeft(lineNo)
442         } else {
443             getLineRight(lineNo)
444         }
445