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