1 /* 2 * Copyright (C) 2021 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 package com.android.systemui.shared.clocks 17 18 import android.animation.TimeInterpolator 19 import android.annotation.ColorInt 20 import android.annotation.FloatRange 21 import android.annotation.IntRange 22 import android.annotation.SuppressLint 23 import android.content.Context 24 import android.graphics.Canvas 25 import android.text.Layout 26 import android.text.TextUtils 27 import android.text.format.DateFormat 28 import android.util.AttributeSet 29 import android.util.MathUtils.constrainedMap 30 import android.widget.TextView 31 import com.android.app.animation.Interpolators 32 import com.android.internal.annotations.VisibleForTesting 33 import com.android.systemui.animation.GlyphCallback 34 import com.android.systemui.animation.TextAnimator 35 import com.android.systemui.customization.R 36 import com.android.systemui.log.core.Logger 37 import com.android.systemui.log.core.MessageBuffer 38 import java.io.PrintWriter 39 import java.util.Calendar 40 import java.util.Locale 41 import java.util.TimeZone 42 43 /** 44 * Displays the time with the hour positioned above the minutes. (ie: 09 above 30 is 9:30) 45 * The time's text color is a gradient that changes its colors based on its controller. 46 */ 47 @SuppressLint("AppCompatCustomView") 48 class AnimatableClockView @JvmOverloads constructor( 49 context: Context, 50 attrs: AttributeSet? = null, 51 defStyleAttr: Int = 0, 52 defStyleRes: Int = 0 53 ) : TextView(context, attrs, defStyleAttr, defStyleRes) { 54 var messageBuffer: MessageBuffer? = null 55 set(value) { 56 logger = if (value != null) Logger(value, TAG) else null 57 } 58 59 private var logger: Logger? = null 60 61 private val time = Calendar.getInstance() 62 63 private val dozingWeightInternal: Int 64 private val lockScreenWeightInternal: Int 65 private val isSingleLineInternal: Boolean 66 67 private var format: CharSequence? = null 68 private var descFormat: CharSequence? = null 69 70 @ColorInt 71 private var dozingColor = 0 72 73 @ColorInt 74 private var lockScreenColor = 0 75 76 private var lineSpacingScale = 1f 77 private val chargeAnimationDelay: Int 78 private var textAnimator: TextAnimator? = null 79 private var onTextAnimatorInitialized: Runnable? = null 80 81 @VisibleForTesting var textAnimatorFactory: (Layout, () -> Unit) -> TextAnimator = 82 { layout, invalidateCb -> 83 TextAnimator(layout, NUM_CLOCK_FONT_ANIMATION_STEPS, invalidateCb) } 84 @VisibleForTesting var isAnimationEnabled: Boolean = true 85 @VisibleForTesting var timeOverrideInMillis: Long? = null 86 87 val dozingWeight: Int 88 get() = if (useBoldedVersion()) dozingWeightInternal + 100 else dozingWeightInternal 89 90 val lockScreenWeight: Int 91 get() = if (useBoldedVersion()) lockScreenWeightInternal + 100 else lockScreenWeightInternal 92 93 /** 94 * The number of pixels below the baseline. For fonts that support languages such as 95 * Burmese, this space can be significant and should be accounted for when computing layout. 96 */ 97 val bottom get() = paint?.fontMetrics?.bottom ?: 0f 98 99 init { 100 val animatableClockViewAttributes = context.obtainStyledAttributes( 101 attrs, R.styleable.AnimatableClockView, defStyleAttr, defStyleRes 102 ) 103 104 try { 105 dozingWeightInternal = animatableClockViewAttributes.getInt( 106 R.styleable.AnimatableClockView_dozeWeight, 107 100 108 ) 109 lockScreenWeightInternal = animatableClockViewAttributes.getInt( 110 R.styleable.AnimatableClockView_lockScreenWeight, 111 300 112 ) 113 chargeAnimationDelay = animatableClockViewAttributes.getInt( 114 R.styleable.AnimatableClockView_chargeAnimationDelay, 200 115 ) 116 } finally { 117 animatableClockViewAttributes.recycle() 118 } 119 120 val textViewAttributes = context.obtainStyledAttributes( 121 attrs, android.R.styleable.TextView, 122 defStyleAttr, defStyleRes 123 ) 124 125 isSingleLineInternal = 126 try { 127 textViewAttributes.getBoolean(android.R.styleable.TextView_singleLine, false) 128 } finally { 129 textViewAttributes.recycle() 130 } 131 132 refreshFormat() 133 } 134 135 override fun onAttachedToWindow() { 136 super.onAttachedToWindow() 137 logger?.d("onAttachedToWindow") 138 refreshFormat() 139 } 140 141 /** 142 * Whether to use a bolded version based on the user specified fontWeightAdjustment. 143 */ 144 fun useBoldedVersion(): Boolean { 145 // "Bold text" fontWeightAdjustment is 300. 146 return resources.configuration.fontWeightAdjustment > 100 147 } 148 149 fun refreshTime() { 150 time.timeInMillis = timeOverrideInMillis ?: System.currentTimeMillis() 151 contentDescription = DateFormat.format(descFormat, time) 152 val formattedText = DateFormat.format(format, time) 153 logger?.d({ "refreshTime: new formattedText=$str1" }) { str1 = formattedText?.toString() } 154 // Setting text actually triggers a layout pass (because the text view is set to 155 // wrap_content width and TextView always relayouts for this). Avoid needless 156 // relayout if the text didn't actually change. 157 if (!TextUtils.equals(text, formattedText)) { 158 text = formattedText 159 logger?.d({ "refreshTime: done setting new time text to: $str1" }) { 160 str1 = formattedText?.toString() 161 } 162 // Because the TextLayout may mutate under the hood as a result of the new text, we 163 // notify the TextAnimator that it may have changed and request a measure/layout. A 164 // crash will occur on the next invocation of setTextStyle if the layout is mutated 165 // without being notified TextInterpolator being notified. 166 if (layout != null) { 167 textAnimator?.updateLayout(layout) 168 logger?.d("refreshTime: done updating textAnimator layout") 169 } 170 requestLayout() 171 logger?.d("refreshTime: after requestLayout") 172 } 173 } 174 175 fun onTimeZoneChanged(timeZone: TimeZone?) { 176 time.timeZone = timeZone 177 refreshFormat() 178 logger?.d({ "onTimeZoneChanged newTimeZone=$str1" }) { str1 = timeZone?.toString() } 179 } 180 181 @SuppressLint("DrawAllocation") 182 override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { 183 super.onMeasure(widthMeasureSpec, heightMeasureSpec) 184 val animator = textAnimator 185 if (animator == null) { 186 textAnimator = textAnimatorFactory(layout, ::invalidate) 187 onTextAnimatorInitialized?.run() 188 onTextAnimatorInitialized = null 189 } else { 190 animator.updateLayout(layout) 191 } 192 logger?.d("onMeasure") 193 } 194 195 override fun onDraw(canvas: Canvas) { 196 // Use textAnimator to render text if animation is enabled. 197 // Otherwise default to using standard draw functions. 198 if (isAnimationEnabled) { 199 // intentionally doesn't call super.onDraw here or else the text will be rendered twice 200 textAnimator?.draw(canvas) 201 } else { 202 super.onDraw(canvas) 203 } 204 logger?.d("onDraw") 205 } 206 207 override fun invalidate() { 208 super.invalidate() 209 logger?.d("invalidate") 210 } 211 212 override fun onTextChanged( 213 text: CharSequence, 214 start: Int, 215 lengthBefore: Int, 216 lengthAfter: Int 217 ) { 218 super.onTextChanged(text, start, lengthBefore, lengthAfter) 219 logger?.d({ "onTextChanged text=$str1" }) { str1 = text.toString() } 220 } 221 222 fun setLineSpacingScale(scale: Float) { 223 lineSpacingScale = scale 224 setLineSpacing(0f, lineSpacingScale) 225 } 226 227 fun setColors(@ColorInt dozingColor: Int, lockScreenColor: Int) { 228 this.dozingColor = dozingColor 229 this.lockScreenColor = lockScreenColor 230 } 231 232 fun animateColorChange() { 233 logger?.d("animateColorChange") 234 setTextStyle( 235 weight = lockScreenWeight, 236 textSize = -1f, 237 color = null, /* using current color */ 238 animate = false, 239 duration = 0, 240 delay = 0, 241 onAnimationEnd = null 242 ) 243 setTextStyle( 244 weight = lockScreenWeight, 245 textSize = -1f, 246 color = lockScreenColor, 247 animate = true, 248 duration = COLOR_ANIM_DURATION, 249 delay = 0, 250 onAnimationEnd = null 251 ) 252 } 253 254 fun animateAppearOnLockscreen() { 255 logger?.d("animateAppearOnLockscreen") 256 setTextStyle( 257 weight = dozingWeight, 258 textSize = -1f, 259 color = lockScreenColor, 260 animate = false, 261 duration = 0, 262 delay = 0, 263 onAnimationEnd = null 264 ) 265 setTextStyle( 266 weight = lockScreenWeight, 267 textSize = -1f, 268 color = lockScreenColor, 269 animate = isAnimationEnabled, 270 duration = APPEAR_ANIM_DURATION, 271 interpolator = Interpolators.EMPHASIZED_DECELERATE, 272 delay = 0, 273 onAnimationEnd = null 274 ) 275 } 276 277 fun animateFoldAppear(animate: Boolean = true) { 278 if (isAnimationEnabled && textAnimator == null) { 279 return 280 } 281 logger?.d("animateFoldAppear") 282 setTextStyle( 283 weight = lockScreenWeightInternal, 284 textSize = -1f, 285 color = lockScreenColor, 286 animate = false, 287 duration = 0, 288 delay = 0, 289 onAnimationEnd = null 290 ) 291 setTextStyle( 292 weight = dozingWeightInternal, 293 textSize = -1f, 294 color = dozingColor, 295 animate = animate && isAnimationEnabled, 296 interpolator = Interpolators.EMPHASIZED_DECELERATE, 297 duration = ANIMATION_DURATION_FOLD_TO_AOD.toLong(), 298 delay = 0, 299 onAnimationEnd = null 300 ) 301 } 302 303 fun animateCharge(isDozing: () -> Boolean) { 304 if (textAnimator == null || textAnimator!!.isRunning()) { 305 // Skip charge animation if dozing animation is already playing. 306 return 307 } 308 logger?.d("animateCharge") 309 val startAnimPhase2 = Runnable { 310 setTextStyle( 311 weight = if (isDozing()) dozingWeight else lockScreenWeight, 312 textSize = -1f, 313 color = null, 314 animate = isAnimationEnabled, 315 duration = CHARGE_ANIM_DURATION_PHASE_1, 316 delay = 0, 317 onAnimationEnd = null 318 ) 319 } 320 setTextStyle( 321 weight = if (isDozing()) lockScreenWeight else dozingWeight, 322 textSize = -1f, 323 color = null, 324 animate = isAnimationEnabled, 325 duration = CHARGE_ANIM_DURATION_PHASE_0, 326 delay = chargeAnimationDelay.toLong(), 327 onAnimationEnd = startAnimPhase2 328 ) 329 } 330 331 fun animateDoze(isDozing: Boolean, animate: Boolean) { 332 logger?.d("animateDoze") 333 setTextStyle( 334 weight = if (isDozing) dozingWeight else lockScreenWeight, 335 textSize = -1f, 336 color = if (isDozing) dozingColor else lockScreenColor, 337 animate = animate && isAnimationEnabled, 338 duration = DOZE_ANIM_DURATION, 339 delay = 0, 340 onAnimationEnd = null 341 ) 342 } 343 344 // The offset of each glyph from where it should be. 345 private var glyphOffsets = mutableListOf(0.0f, 0.0f, 0.0f, 0.0f) 346 347 private var lastSeenAnimationProgress = 1.0f 348 349 // If the animation is being reversed, the target offset for each glyph for the "stop". 350 private var animationCancelStartPosition = mutableListOf(0.0f, 0.0f, 0.0f, 0.0f) 351 private var animationCancelStopPosition = 0.0f 352 353 // Whether the currently playing animation needed a stop (and thus, is shortened). 354 private var currentAnimationNeededStop = false 355 356 private val glyphFilter: GlyphCallback = { positionedGlyph, _ -> 357 val offset = positionedGlyph.lineNo * DIGITS_PER_LINE + positionedGlyph.glyphIndex 358 if (offset < glyphOffsets.size) { 359 positionedGlyph.x += glyphOffsets[offset] 360 } 361 } 362 363 /** 364 * Set text style with an optional animation. 365 * 366 * By passing -1 to weight, the view preserves its current weight. 367 * By passing -1 to textSize, the view preserves its current text size. 368 * By passing null to color, the view preserves its current color. 369 * 370 * @param weight text weight. 371 * @param textSize font size. 372 * @param animate true to animate the text style change, otherwise false. 373 */ 374 private fun setTextStyle( 375 @IntRange(from = 0, to = 1000) weight: Int, 376 @FloatRange(from = 0.0) textSize: Float, 377 color: Int?, 378 animate: Boolean, 379 interpolator: TimeInterpolator?, 380 duration: Long, 381 delay: Long, 382 onAnimationEnd: Runnable? 383 ) { 384 if (textAnimator != null) { 385 textAnimator?.setTextStyle( 386 weight = weight, 387 textSize = textSize, 388 color = color, 389 animate = animate && isAnimationEnabled, 390 duration = duration, 391 interpolator = interpolator, 392 delay = delay, 393 onAnimationEnd = onAnimationEnd 394 ) 395 textAnimator?.glyphFilter = glyphFilter 396 if (color != null && !isAnimationEnabled) { 397 setTextColor(color) 398 } 399 } else { 400 // when the text animator is set, update its start values 401 onTextAnimatorInitialized = Runnable { 402 textAnimator?.setTextStyle( 403 weight = weight, 404 textSize = textSize, 405 color = color, 406 animate = false, 407 duration = duration, 408 interpolator = interpolator, 409 delay = delay, 410 onAnimationEnd = onAnimationEnd 411 ) 412 textAnimator?.glyphFilter = glyphFilter 413 if (color != null && !isAnimationEnabled) { 414 setTextColor(color) 415 } 416 } 417 } 418 } 419 420 private fun setTextStyle( 421 @IntRange(from = 0, to = 1000) weight: Int, 422 @FloatRange(from = 0.0) textSize: Float, 423 color: Int?, 424 animate: Boolean, 425 duration: Long, 426 delay: Long, 427 onAnimationEnd: Runnable? 428 ) { 429 setTextStyle( 430 weight = weight, 431 textSize = textSize, 432 color = color, 433 animate = animate && isAnimationEnabled, 434 interpolator = null, 435 duration = duration, 436 delay = delay, 437 onAnimationEnd = onAnimationEnd 438 ) 439 } 440 441 fun refreshFormat() = refreshFormat(DateFormat.is24HourFormat(context)) 442 fun refreshFormat(use24HourFormat: Boolean) { 443 Patterns.update(context) 444 445 format = when { 446 isSingleLineInternal && use24HourFormat -> Patterns.sClockView24 447 !isSingleLineInternal && use24HourFormat -> DOUBLE_LINE_FORMAT_24_HOUR 448 isSingleLineInternal && !use24HourFormat -> Patterns.sClockView12 449 else -> DOUBLE_LINE_FORMAT_12_HOUR 450 } 451 logger?.d({ "refreshFormat format=$str1" }) { str1 = format?.toString() } 452 453 descFormat = if (use24HourFormat) Patterns.sClockView24 else Patterns.sClockView12 454 refreshTime() 455 } 456 457 fun dump(pw: PrintWriter) { 458 pw.println("$this") 459 pw.println(" alpha=$alpha") 460 pw.println(" measuredWidth=$measuredWidth") 461 pw.println(" measuredHeight=$measuredHeight") 462 pw.println(" singleLineInternal=$isSingleLineInternal") 463 pw.println(" currText=$text") 464 pw.println(" currTimeContextDesc=$contentDescription") 465 pw.println(" dozingWeightInternal=$dozingWeightInternal") 466 pw.println(" lockScreenWeightInternal=$lockScreenWeightInternal") 467 pw.println(" dozingColor=$dozingColor") 468 pw.println(" lockScreenColor=$lockScreenColor") 469 pw.println(" time=$time") 470 } 471 472 private val moveToCenterDelays 473 get() = if (isLayoutRtl) MOVE_LEFT_DELAYS else MOVE_RIGHT_DELAYS 474 475 private val moveToSideDelays 476 get() = if (isLayoutRtl) MOVE_RIGHT_DELAYS else MOVE_LEFT_DELAYS 477 478 /** 479 * Offsets the glyphs of the clock for the step clock animation. 480 * 481 * The animation makes the glyphs of the clock move at different speeds, when the clock is 482 * moving horizontally. 483 * 484 * @param clockStartLeft the [getLeft] position of the clock, before it started moving. 485 * @param clockMoveDirection the direction in which it is moving. A positive number means right, 486 * and negative means left. 487 * @param moveFraction fraction of the clock movement. 0 means it is at the beginning, and 1 488 * means it finished moving. 489 */ 490 fun offsetGlyphsForStepClockAnimation( 491 clockStartLeft: Int, 492 clockMoveDirection: Int, 493 moveFraction: Float 494 ) { 495 val isMovingToCenter = if (isLayoutRtl) clockMoveDirection < 0 else clockMoveDirection > 0 496 val currentMoveAmount = left - clockStartLeft 497 val digitOffsetDirection = if (isLayoutRtl) -1 else 1 498 for (i in 0 until NUM_DIGITS) { 499 // The delay for the digit, in terms of fraction (i.e. the digit should not move 500 // during 0.0 - 0.1). 501 val digitInitialDelay = 502 if (isMovingToCenter) { 503 moveToCenterDelays[i] * MOVE_DIGIT_STEP 504 } else { 505 moveToSideDelays[i] * MOVE_DIGIT_STEP 506 } 507 val digitFraction = 508 MOVE_INTERPOLATOR.getInterpolation( 509 constrainedMap( 510 0.0f, 511 1.0f, 512 digitInitialDelay, 513 digitInitialDelay + AVAILABLE_ANIMATION_TIME, 514 moveFraction 515 ) 516 ) 517 val moveAmountForDigit = currentMoveAmount * digitFraction 518 val moveAmountDeltaForDigit = moveAmountForDigit - currentMoveAmount 519 glyphOffsets[i] = digitOffsetDirection * moveAmountDeltaForDigit 520 } 521 invalidate() 522 } 523 524 // DateFormat.getBestDateTimePattern is extremely expensive, and refresh is called often. 525 // This is an optimization to ensure we only recompute the patterns when the inputs change. 526 private object Patterns { 527 var sClockView12: String? = null 528 var sClockView24: String? = null 529 var sCacheKey: String? = null 530 531 fun update(context: Context) { 532 val locale = Locale.getDefault() 533 val res = context.resources 534 val clockView12Skel = res.getString(R.string.clock_12hr_format) 535 val clockView24Skel = res.getString(R.string.clock_24hr_format) 536 val key = locale.toString() + clockView12Skel + clockView24Skel 537 if (key == sCacheKey) return 538 539 val clockView12 = DateFormat.getBestDateTimePattern(locale, clockView12Skel) 540 sClockView12 = clockView12 541 542 // CLDR insists on adding an AM/PM indicator even though it wasn't in the skeleton 543 // format. The following code removes the AM/PM indicator if we didn't want it. 544 if (!clockView12Skel.contains("a")) { 545 sClockView12 = clockView12.replace("a".toRegex(), "").trim { it <= ' ' } 546 } 547 548 sClockView24 = DateFormat.getBestDateTimePattern(locale, clockView24Skel) 549 sCacheKey = key 550 } 551 } 552 553 companion object { 554 private val TAG = AnimatableClockView::class.simpleName!! 555 const val ANIMATION_DURATION_FOLD_TO_AOD: Int = 600 556 private const val DOUBLE_LINE_FORMAT_12_HOUR = "hh\nmm" 557 private const val DOUBLE_LINE_FORMAT_24_HOUR = "HH\nmm" 558 private const val DOZE_ANIM_DURATION: Long = 300 559 private const val APPEAR_ANIM_DURATION: Long = 833 560 private const val CHARGE_ANIM_DURATION_PHASE_0: Long = 500 561 private const val CHARGE_ANIM_DURATION_PHASE_1: Long = 1000 562 private const val COLOR_ANIM_DURATION: Long = 400 563 private const val NUM_CLOCK_FONT_ANIMATION_STEPS = 30 564 565 // Constants for the animation 566 private val MOVE_INTERPOLATOR = Interpolators.EMPHASIZED 567 568 // Calculate the positions of all of the digits... 569 // Offset each digit by, say, 0.1 570 // This means that each digit needs to move over a slice of "fractions", i.e. digit 0 should 571 // move from 0.0 - 0.7, digit 1 from 0.1 - 0.8, digit 2 from 0.2 - 0.9, and digit 3 572 // from 0.3 - 1.0. 573 private const val NUM_DIGITS = 4 574 private const val DIGITS_PER_LINE = 2 575 576 // Delays. Each digit's animation should have a slight delay, so we get a nice 577 // "stepping" effect. When moving right, the second digit of the hour should move first. 578 // When moving left, the first digit of the hour should move first. The lists encode 579 // the delay for each digit (hour[0], hour[1], minute[0], minute[1]), to be multiplied 580 // by delayMultiplier. 581 private val MOVE_LEFT_DELAYS = listOf(0, 1, 2, 3) 582 private val MOVE_RIGHT_DELAYS = listOf(1, 0, 3, 2) 583 584 // How much delay to apply to each subsequent digit. This is measured in terms of "fraction" 585 // (i.e. a value of 0.1 would cause a digit to wait until fraction had hit 0.1, or 0.2 etc 586 // before moving). 587 // 588 // The current specs dictate that each digit should have a 33ms gap between them. The 589 // overall time is 1s right now. 590 private const val MOVE_DIGIT_STEP = 0.033f 591 592 // Total available transition time for each digit, taking into account the step. If step is 593 // 0.1, then digit 0 would animate over 0.0 - 0.7, making availableTime 0.7. 594 private const val AVAILABLE_ANIMATION_TIME = 1.0f - MOVE_DIGIT_STEP * (NUM_DIGITS - 1) 595 } 596 } 597