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