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