• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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