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