• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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.Log
29 import android.util.LruCache
30 import androidx.annotation.VisibleForTesting
31 import com.android.app.animation.Interpolators
32 
33 typealias GlyphCallback = (TextAnimator.PositionedGlyph, Float) -> Unit
34 
35 interface TypefaceVariantCache {
36     val fontCache: FontCache
37     val animationFrameCount: Int
38 
getTypefaceForVariantnull39     fun getTypefaceForVariant(fvar: String?): Typeface?
40 
41     companion object {
42         @JvmStatic
43         fun createVariantTypeface(baseTypeface: Typeface, fVar: String?): Typeface {
44             if (fVar.isNullOrEmpty()) {
45                 return baseTypeface
46             }
47 
48             val axes =
49                 FontVariationAxis.fromFontVariationSettings(fVar)?.toMutableList()
50                     ?: mutableListOf()
51             axes.removeIf { !baseTypeface.isSupportedAxes(it.getOpenTypeTagValue()) }
52 
53             if (axes.isEmpty()) {
54                 return baseTypeface
55             } else {
56                 return Typeface.createFromTypefaceWithVariation(baseTypeface, axes)
57             }
58         }
59     }
60 }
61 
62 class TypefaceVariantCacheImpl(var baseTypeface: Typeface, override val animationFrameCount: Int) :
63     TypefaceVariantCache {
64     private val cache = LruCache<String, Typeface>(TYPEFACE_CACHE_MAX_ENTRIES)
65     override val fontCache = FontCacheImpl(animationFrameCount)
66 
getTypefaceForVariantnull67     override fun getTypefaceForVariant(fvar: String?): Typeface? {
68         if (fvar == null) {
69             return baseTypeface
70         }
71         cache.get(fvar)?.let {
72             return it
73         }
74 
75         return TypefaceVariantCache.createVariantTypeface(baseTypeface, fvar).also {
76             cache.put(fvar, it)
77         }
78     }
79 
80     companion object {
81         private const val TYPEFACE_CACHE_MAX_ENTRIES = 5
82     }
83 }
84 
85 interface TextAnimatorListener : TextInterpolatorListener {
onInvalidatenull86     fun onInvalidate() {}
87 }
88 
89 /**
90  * This class provides text animation between two styles.
91  *
92  * Currently this class can provide text style animation for text weight and text size. For example
93  * the simple view that draws text with animating text size is like as follows:
94  * <pre> <code>
95  * ```
96  *     class SimpleTextAnimation : View {
97  *         @JvmOverloads constructor(...)
98  *
99  *         private val layout: Layout = ... // Text layout, e.g. StaticLayout.
100  *
101  *         // TextAnimator tells us when needs to be invalidate.
102  *         private val animator = TextAnimator(layout) { invalidate() }
103  *
104  *         override fun onDraw(canvas: Canvas) = animator.draw(canvas)
105  *
106  *         // Change the text size with animation.
107  *         fun setTextSize(sizePx: Float, animate: Boolean) {
108  *             animator.setTextStyle("" /* unchanged fvar... */, sizePx, animate)
109  *         }
110  *     }
111  * ```
112  * </code> </pre>
113  */
114 class TextAnimator(
115     layout: Layout,
116     private val typefaceCache: TypefaceVariantCache,
117     private val listener: TextAnimatorListener? = null,
118 ) {
119     var textInterpolator = TextInterpolator(layout, typefaceCache, listener)
<lambda>null120     @VisibleForTesting var createAnimator: () -> ValueAnimator = { ValueAnimator.ofFloat(1f) }
121 
122     var animator: ValueAnimator? = null
123 
124     val progress: Float
125         get() = textInterpolator.progress
126 
127     val linearProgress: Float
128         get() = textInterpolator.linearProgress
129 
130     val fontVariationUtils = FontVariationUtils()
131 
132     sealed class PositionedGlyph {
133         /** Mutable X coordinate of the glyph position relative from drawing offset. */
134         var x: Float = 0f
135 
136         /** Mutable Y coordinate of the glyph position relative from the baseline. */
137         var y: Float = 0f
138 
139         /** The current line of text being drawn, in a multi-line TextView. */
140         var lineNo: Int = 0
141 
142         /** Mutable text size of the glyph in pixels. */
143         var textSize: Float = 0f
144 
145         /** Mutable color of the glyph. */
146         var color: Int = 0
147 
148         /** Immutable character offset in the text that the current font run start. */
149         abstract var runStart: Int
150             protected set
151 
152         /** Immutable run length of the font run. */
153         abstract var runLength: Int
154             protected set
155 
156         /** Immutable glyph index of the font run. */
157         abstract var glyphIndex: Int
158             protected set
159 
160         /** Immutable font instance for this font run. */
161         abstract var font: Font
162             protected set
163 
164         /** Immutable glyph ID for this glyph. */
165         abstract var glyphId: Int
166             protected set
167     }
168 
updateLayoutnull169     fun updateLayout(layout: Layout, textSize: Float = -1f) {
170         textInterpolator.layout = layout
171 
172         if (textSize >= 0) {
173             textInterpolator.targetPaint.textSize = textSize
174             textInterpolator.basePaint.textSize = textSize
175             textInterpolator.onTargetPaintModified()
176             textInterpolator.onBasePaintModified()
177         }
178     }
179 
180     val isRunning: Boolean
181         get() = animator?.isRunning ?: false
182 
183     /**
184      * GlyphFilter applied just before drawing to canvas for tweaking positions and text size.
185      *
186      * This callback is called for each glyphs just before drawing the glyphs. This function will be
187      * called with the intrinsic position, size, color, glyph ID and font instance. You can mutate
188      * the position, size and color for tweaking animations. Do not keep the reference of passed
189      * glyph object. The interpolator reuses that object for avoiding object allocations.
190      *
191      * Details: The text is drawn with font run units. The font run is a text segment that draws
192      * with the same font. The {@code runStart} and {@code runLimit} is a range of the font run in
193      * the text that current glyph is in. Once the font run is determined, the system will convert
194      * characters into glyph IDs. The {@code glyphId} is the glyph identifier in the font and {@code
195      * glyphIndex} is the offset of the converted glyph array. Please note that the {@code
196      * glyphIndex} is not a character index, because the character will not be converted to glyph
197      * one-by-one. If there are ligatures including emoji sequence, etc, the glyph ID may be
198      * composed from multiple characters.
199      *
200      * Here is an example of font runs: "fin. 終わり"
201      *
202      * ```
203      * Characters :    f      i      n      .      _      終     わ     り
204      * Code Points: \u0066 \u0069 \u006E \u002E \u0020 \u7D42 \u308F \u308A
205      * Font Runs  : <-- Roboto-Regular.ttf          --><-- NotoSans-CJK.otf -->
206      *                  runStart = 0, runLength = 5        runStart = 5, runLength = 3
207      * Glyph IDs  :      194        48     7      8     4367   1039   1002
208      * Glyph Index:       0          1     2      3       0      1      2
209      * ```
210      *
211      * In this example, the "fi" is converted into ligature form, thus the single glyph ID is
212      * assigned for two characters, f and i.
213      *
214      * Example:
215      * ```
216      * private val glyphFilter: GlyphCallback = { glyph, progress ->
217      *     val index = glyph.runStart
218      *     val i = glyph.glyphIndex
219      *     val moveAmount = 1.3f
220      *     val sign = (-1 + 2 * ((i + index) % 2))
221      *     val turnProgress = if (progress < .5f) progress / 0.5f else (1.0f - progress) / 0.5f
222      *
223      *     // You can modify (x, y) coordinates, textSize and color during animation.
224      *     glyph.textSize += glyph.textSize * sign * moveAmount * turnProgress
225      *     glyph.y += glyph.y * sign * moveAmount * turnProgress
226      *     glyph.x += glyph.x * sign * moveAmount * turnProgress
227      * }
228      * ```
229      */
230     var glyphFilter: GlyphCallback?
231         get() = textInterpolator.glyphFilter
232         set(value) {
233             textInterpolator.glyphFilter = value
234         }
235 
drawnull236     fun draw(c: Canvas) = textInterpolator.draw(c)
237 
238     /** Style spec to use when rendering the font */
239     data class Style(
240         val fVar: String? = null,
241         val textSize: Float? = null,
242         val color: Int? = null,
243         val strokeWidth: Float? = null,
244     ) {
245         fun withUpdatedFVar(
246             fontVariationUtils: FontVariationUtils,
247             weight: Int = -1,
248             width: Int = -1,
249             opticalSize: Int = -1,
250             roundness: Int = -1,
251         ): Style {
252             return this.copy(
253                 fVar =
254                     fontVariationUtils.updateFontVariation(
255                         weight = weight,
256                         width = width,
257                         opticalSize = opticalSize,
258                         roundness = roundness,
259                     )
260             )
261         }
262     }
263 
264     /** Animation Spec for use when style changes should be animated */
265     data class Animation(
266         val animate: Boolean = true,
267         val startDelay: Long = 0,
268         val duration: Long = DEFAULT_ANIMATION_DURATION,
269         val interpolator: TimeInterpolator = Interpolators.LINEAR,
270         val onAnimationEnd: Runnable? = null,
271     ) {
configureAnimatornull272         fun configureAnimator(animator: Animator) {
273             animator.startDelay = startDelay
274             animator.duration = duration
275             animator.interpolator = interpolator
276             if (onAnimationEnd != null) {
277                 animator.addListener(
278                     object : AnimatorListenerAdapter() {
279                         override fun onAnimationEnd(animation: Animator) {
280                             onAnimationEnd.run()
281                         }
282                     }
283                 )
284             }
285         }
286 
287         companion object {
288             val DISABLED = Animation(animate = false)
289         }
290     }
291 
292     /** Sets the text style, optionally with animation */
setTextStylenull293     fun setTextStyle(style: Style, animation: Animation = Animation.DISABLED) {
294         animator?.cancel()
295         setTextStyleInternal(style, rebase = animation.animate)
296 
297         if (animation.animate) {
298             animator = buildAnimator(animation).apply { start() }
299         } else {
300             textInterpolator.progress = 1f
301             textInterpolator.linearProgress = 1f
302             textInterpolator.rebase()
303             listener?.onInvalidate()
304         }
305     }
306 
307     /** Builds a ValueAnimator from the specified animation parameters */
buildAnimatornull308     private fun buildAnimator(animation: Animation): ValueAnimator {
309         return createAnimator().apply {
310             duration = DEFAULT_ANIMATION_DURATION
311             animation.configureAnimator(this)
312 
313             addUpdateListener {
314                 textInterpolator.progress = it.animatedValue as Float
315                 textInterpolator.linearProgress = it.currentPlayTime / it.duration.toFloat()
316                 listener?.onInvalidate()
317             }
318 
319             addListener(
320                 object : AnimatorListenerAdapter() {
321                     override fun onAnimationEnd(animator: Animator) = textInterpolator.rebase()
322 
323                     override fun onAnimationCancel(animator: Animator) = textInterpolator.rebase()
324                 }
325             )
326         }
327     }
328 
setTextStyleInternalnull329     private fun setTextStyleInternal(
330         style: Style,
331         rebase: Boolean,
332         updateLayoutOnFailure: Boolean = true,
333     ) {
334         try {
335             if (rebase) textInterpolator.rebase()
336             style.color?.let { textInterpolator.targetPaint.color = it }
337             style.textSize?.let { textInterpolator.targetPaint.textSize = it }
338             style.strokeWidth?.let { textInterpolator.targetPaint.strokeWidth = it }
339             style.fVar?.let {
340                 textInterpolator.targetPaint.typeface = typefaceCache.getTypefaceForVariant(it)
341             }
342             textInterpolator.onTargetPaintModified()
343         } catch (ex: IllegalArgumentException) {
344             if (updateLayoutOnFailure) {
345                 Log.e(
346                     TAG,
347                     "setTextStyleInternal: Exception caught but retrying. This is usually" +
348                         " due to the layout having changed unexpectedly without being notified.",
349                     ex,
350                 )
351 
352                 updateLayout(textInterpolator.layout)
353                 setTextStyleInternal(style, rebase, updateLayoutOnFailure = false)
354             } else {
355                 throw ex
356             }
357         }
358     }
359 
360     companion object {
361         private val TAG = TextAnimator::class.simpleName!!
362         const val DEFAULT_ANIMATION_DURATION = 300L
363     }
364 }
365