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