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