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