xref: /aosp_15_r20/frameworks/base/packages/SystemUI/animation/src/com/android/systemui/animation/TextAnimator.kt (revision d57664e9bc4670b3ecf6748a746a57c557b6bc9e)
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 
17 package com.android.systemui.animation
18 
19 import android.animation.Animator
20 import android.animation.AnimatorListenerAdapter
21 import android.animation.TimeInterpolator
22 import android.animation.ValueAnimator
23 import android.graphics.Canvas
24 import android.graphics.Typeface
25 import android.graphics.fonts.Font
26 import android.graphics.fonts.FontVariationAxis
27 import android.text.Layout
28 import android.util.LruCache
29 import kotlin.math.roundToInt
30 import android.util.Log
31 
32 private const val DEFAULT_ANIMATION_DURATION: Long = 300
33 private const val TYPEFACE_CACHE_MAX_ENTRIES = 5
34 
35 typealias GlyphCallback = (TextAnimator.PositionedGlyph, Float) -> Unit
36 
37 interface TypefaceVariantCache {
38     val fontCache: FontCache
39     val animationFrameCount: Int
getTypefaceForVariantnull40     fun getTypefaceForVariant(fvar: String?): Typeface?
41 
42     companion object {
43         fun createVariantTypeface(baseTypeface: Typeface, fVar: String?): Typeface {
44             if (fVar.isNullOrEmpty()) {
45                 return baseTypeface
46             }
47 
48             val axes = FontVariationAxis.fromFontVariationSettings(fVar)
49                 ?.toMutableList()
50                 ?: mutableListOf()
51             axes.removeIf { !baseTypeface.isSupportedAxes(it.getOpenTypeTagValue()) }
52             if (axes.isEmpty()) {
53                 return baseTypeface
54             }
55             return Typeface.createFromTypefaceWithVariation(baseTypeface, axes)
56         }
57     }
58 }
59 
60 class TypefaceVariantCacheImpl(
61     var baseTypeface: Typeface,
62     override val animationFrameCount: Int,
63 ) : TypefaceVariantCache {
64     private val cache = LruCache<String, Typeface>(TYPEFACE_CACHE_MAX_ENTRIES)
65     override val fontCache = FontCacheImpl(animationFrameCount)
getTypefaceForVariantnull66     override fun getTypefaceForVariant(fvar: String?): Typeface? {
67         if (fvar == null) {
68             return baseTypeface
69         }
70         cache.get(fvar)?.let {
71             return it
72         }
73 
74         return TypefaceVariantCache.createVariantTypeface(baseTypeface, fvar).also {
75             cache.put(fvar, it)
76         }
77     }
78 }
79 
80 /**
81  * This class provides text animation between two styles.
82  *
83  * Currently this class can provide text style animation for text weight and text size. For example
84  * the simple view that draws text with animating text size is like as follows:
85  * <pre> <code>
86  * ```
87  *     class SimpleTextAnimation : View {
88  *         @JvmOverloads constructor(...)
89  *
90  *         private val layout: Layout = ... // Text layout, e.g. StaticLayout.
91  *
92  *         // TextAnimator tells us when needs to be invalidate.
93  *         private val animator = TextAnimator(layout) { invalidate() }
94  *
95  *         override fun onDraw(canvas: Canvas) = animator.draw(canvas)
96  *
97  *         // Change the text size with animation.
98  *         fun setTextSize(sizePx: Float, animate: Boolean) {
99  *             animator.setTextStyle("" /* unchanged fvar... */, sizePx, animate)
100  *         }
101  *     }
102  * ```
103  * </code> </pre>
104  */
105 class TextAnimator(
106     layout: Layout,
107     private val typefaceCache: TypefaceVariantCache,
<lambda>null108     private val invalidateCallback: () -> Unit = {},
109 ) {
110     // Following two members are for mutable for testing purposes.
111     public var textInterpolator = TextInterpolator(layout, typefaceCache)
112     public var animator =
<lambda>null113         ValueAnimator.ofFloat(1f).apply {
114             duration = DEFAULT_ANIMATION_DURATION
115             addUpdateListener {
116                 textInterpolator.progress =
117                     calculateProgress(it.animatedValue as Float, typefaceCache.animationFrameCount)
118                 invalidateCallback()
119             }
120             addListener(
121                 object : AnimatorListenerAdapter() {
122                     override fun onAnimationEnd(animation: Animator) = textInterpolator.rebase()
123                     override fun onAnimationCancel(animation: Animator) = textInterpolator.rebase()
124                 }
125             )
126         }
127 
calculateProgressnull128     private fun calculateProgress(animProgress: Float, numberOfAnimationSteps: Int?): Float {
129         if (numberOfAnimationSteps != null) {
130             // This clamps the progress to the nearest value of "numberOfAnimationSteps"
131             // discrete values between 0 and 1f.
132             return (animProgress * numberOfAnimationSteps).roundToInt() /
133                 numberOfAnimationSteps.toFloat()
134         }
135 
136         return animProgress
137     }
138 
139     sealed class PositionedGlyph {
140         /** Mutable X coordinate of the glyph position relative from drawing offset. */
141         var x: Float = 0f
142 
143         /** Mutable Y coordinate of the glyph position relative from the baseline. */
144         var y: Float = 0f
145 
146         /** The current line of text being drawn, in a multi-line TextView. */
147         var lineNo: Int = 0
148 
149         /** Mutable text size of the glyph in pixels. */
150         var textSize: Float = 0f
151 
152         /** Mutable color of the glyph. */
153         var color: Int = 0
154 
155         /** Immutable character offset in the text that the current font run start. */
156         abstract var runStart: Int
157             protected set
158 
159         /** Immutable run length of the font run. */
160         abstract var runLength: Int
161             protected set
162 
163         /** Immutable glyph index of the font run. */
164         abstract var glyphIndex: Int
165             protected set
166 
167         /** Immutable font instance for this font run. */
168         abstract var font: Font
169             protected set
170 
171         /** Immutable glyph ID for this glyph. */
172         abstract var glyphId: Int
173             protected set
174     }
175 
176     private val fontVariationUtils = FontVariationUtils()
177 
updateLayoutnull178     fun updateLayout(layout: Layout, textSize: Float = -1f) {
179         textInterpolator.layout = layout
180 
181         if (textSize >= 0) {
182             textInterpolator.targetPaint.textSize = textSize
183             textInterpolator.basePaint.textSize = textSize
184             textInterpolator.onTargetPaintModified()
185             textInterpolator.onBasePaintModified()
186         }
187     }
188 
isRunningnull189     fun isRunning(): Boolean {
190         return animator.isRunning
191     }
192 
193     /**
194      * GlyphFilter applied just before drawing to canvas for tweaking positions and text size.
195      *
196      * This callback is called for each glyphs just before drawing the glyphs. This function will be
197      * called with the intrinsic position, size, color, glyph ID and font instance. You can mutate
198      * the position, size and color for tweaking animations. Do not keep the reference of passed
199      * glyph object. The interpolator reuses that object for avoiding object allocations.
200      *
201      * Details: The text is drawn with font run units. The font run is a text segment that draws
202      * with the same font. The {@code runStart} and {@code runLimit} is a range of the font run in
203      * the text that current glyph is in. Once the font run is determined, the system will convert
204      * characters into glyph IDs. The {@code glyphId} is the glyph identifier in the font and {@code
205      * glyphIndex} is the offset of the converted glyph array. Please note that the {@code
206      * glyphIndex} is not a character index, because the character will not be converted to glyph
207      * one-by-one. If there are ligatures including emoji sequence, etc, the glyph ID may be
208      * composed from multiple characters.
209      *
210      * Here is an example of font runs: "fin. 終わり"
211      *
212      * Characters :    f      i      n      .      _      終     わ     り
213      * Code Points: \u0066 \u0069 \u006E \u002E \u0020 \u7D42 \u308F \u308A
214      * Font Runs  : <-- Roboto-Regular.ttf          --><-- NotoSans-CJK.otf -->
215      *                  runStart = 0, runLength = 5        runStart = 5, runLength = 3
216      * Glyph IDs  :      194        48     7      8     4367   1039   1002
217      * Glyph Index:       0          1     2      3       0      1      2
218      *
219      * In this example, the "fi" is converted into ligature form, thus the single glyph ID is
220      * assigned for two characters, f and i.
221      *
222      * Example:
223      * ```
224      * private val glyphFilter: GlyphCallback = { glyph, progress ->
225      *     val index = glyph.runStart
226      *     val i = glyph.glyphIndex
227      *     val moveAmount = 1.3f
228      *     val sign = (-1 + 2 * ((i + index) % 2))
229      *     val turnProgress = if (progress < .5f) progress / 0.5f else (1.0f - progress) / 0.5f
230      *
231      *     // You can modify (x, y) coordinates, textSize and color during animation.
232      *     glyph.textSize += glyph.textSize * sign * moveAmount * turnProgress
233      *     glyph.y += glyph.y * sign * moveAmount * turnProgress
234      *     glyph.x += glyph.x * sign * moveAmount * turnProgress
235      * }
236      * ```
237      */
238     var glyphFilter: GlyphCallback?
239         get() = textInterpolator.glyphFilter
240         set(value) {
241             textInterpolator.glyphFilter = value
242         }
243 
drawnull244     fun draw(c: Canvas) = textInterpolator.draw(c)
245 
246     /**
247      * Set text style with animation.
248      *
249      * By passing -1 to weight, the view preserve the current weight.
250      * By passing -1 to textSize, the view preserve the current text size.
251      * Bu passing -1 to duration, the default text animation, 1000ms, is used.
252      * By passing false to animate, the text will be updated without animation.
253      *
254      * @param fvar an optional text fontVariationSettings.
255      * @param textSize an optional font size.
256      * @param colors an optional colors array that must be the same size as numLines passed to
257      *               the TextInterpolator
258      * @param strokeWidth an optional paint stroke width
259      * @param animate an optional boolean indicating true for showing style transition as animation,
260      *                false for immediate style transition. True by default.
261      * @param duration an optional animation duration in milliseconds. This is ignored if animate is
262      *                 false.
263      * @param interpolator an optional time interpolator. If null is passed, last set interpolator
264      *                     will be used. This is ignored if animate is false.
265      */
266     fun setTextStyle(
267         fvar: String? = "",
268         textSize: Float = -1f,
269         color: Int? = null,
270         strokeWidth: Float = -1f,
271         animate: Boolean = true,
272         duration: Long = -1L,
273         interpolator: TimeInterpolator? = null,
274         delay: Long = 0,
275         onAnimationEnd: Runnable? = null,
276     ) = setTextStyleInternal(fvar, textSize, color, strokeWidth, animate, duration,
277         interpolator, delay, onAnimationEnd, updateLayoutOnFailure = true)
278 
279     private fun setTextStyleInternal(
280         fvar: String?,
281         textSize: Float,
282         color: Int?,
283         strokeWidth: Float,
284         animate: Boolean,
285         duration: Long,
286         interpolator: TimeInterpolator?,
287         delay: Long,
288         onAnimationEnd: Runnable?,
289         updateLayoutOnFailure: Boolean,
290     ) {
291         try {
292             if (animate) {
293                 animator.cancel()
294                 textInterpolator.rebase()
295             }
296 
297             if (textSize >= 0) {
298                 textInterpolator.targetPaint.textSize = textSize
299             }
300             if (!fvar.isNullOrBlank()) {
301                 textInterpolator.targetPaint.typeface = typefaceCache.getTypefaceForVariant(fvar)
302             }
303             if (color != null) {
304                 textInterpolator.targetPaint.color = color
305             }
306             if (strokeWidth >= 0F) {
307                 textInterpolator.targetPaint.strokeWidth = strokeWidth
308             }
309             textInterpolator.onTargetPaintModified()
310 
311             if (animate) {
312                 animator.startDelay = delay
313                 animator.duration =
314                     if (duration == -1L) {
315                         DEFAULT_ANIMATION_DURATION
316                     } else {
317                         duration
318                     }
319                 interpolator?.let { animator.interpolator = it }
320                 if (onAnimationEnd != null) {
321                     val listener = object : AnimatorListenerAdapter() {
322                         override fun onAnimationEnd(animation: Animator) {
323                             onAnimationEnd.run()
324                             animator.removeListener(this)
325                         }
326                         override fun onAnimationCancel(animation: Animator) {
327                             animator.removeListener(this)
328                         }
329                     }
330                     animator.addListener(listener)
331                 }
332                 animator.start()
333             } else {
334                 // No animation is requested, thus set base and target state to the same state.
335                 textInterpolator.progress = 1f
336                 textInterpolator.rebase()
337                 invalidateCallback()
338             }
339         } catch (ex: IllegalArgumentException) {
340             if (updateLayoutOnFailure) {
341                 Log.e(TAG, "setTextStyleInternal: Exception caught but retrying. This is usually" +
342                     " due to the layout having changed unexpectedly without being notified.", ex)
343                 updateLayout(textInterpolator.layout)
344                 setTextStyleInternal(fvar, textSize, color, strokeWidth, animate, duration,
345                     interpolator, delay, onAnimationEnd, updateLayoutOnFailure = false)
346             } else {
347                 throw ex
348             }
349         }
350     }
351 
352     /**
353      * Set text style with animation. Similar as
354      * fun setTextStyle(
355      *      fvar: String? = "",
356      *      textSize: Float = -1f,
357      *      color: Int? = null,
358      *      strokeWidth: Float = -1f,
359      *      animate: Boolean = true,
360      *      duration: Long = -1L,
361      *      interpolator: TimeInterpolator? = null,
362      *      delay: Long = 0,
363      *      onAnimationEnd: Runnable? = null
364      * )
365      *
366      * @param weight an optional style value for `wght` in fontVariationSettings.
367      * @param width an optional style value for `wdth` in fontVariationSettings.
368      * @param opticalSize an optional style value for `opsz` in fontVariationSettings.
369      * @param roundness an optional style value for `ROND` in fontVariationSettings.
370      */
setTextStylenull371     fun setTextStyle(
372         weight: Int = -1,
373         width: Int = -1,
374         opticalSize: Int = -1,
375         roundness: Int = -1,
376         textSize: Float = -1f,
377         color: Int? = null,
378         strokeWidth: Float = -1f,
379         animate: Boolean = true,
380         duration: Long = -1L,
381         interpolator: TimeInterpolator? = null,
382         delay: Long = 0,
383         onAnimationEnd: Runnable? = null
384     ) = setTextStyleInternal(
385             fvar = fontVariationUtils.updateFontVariation(
386                 weight = weight,
387                 width = width,
388                 opticalSize = opticalSize,
389                 roundness = roundness,
390             ),
391             textSize = textSize,
392             color = color,
393             strokeWidth = strokeWidth,
394             animate = animate,
395             duration = duration,
396             interpolator = interpolator,
397             delay = delay,
398             onAnimationEnd = onAnimationEnd,
399             updateLayoutOnFailure = true,
400         )
401 
402     companion object {
403         private val TAG = TextAnimator::class.simpleName!!
404     }
405 }
406