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