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