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