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