Migrate AnimatableClockView to SystemUISharedLib
Bug: 229771520
Test: Automated
Change-Id: I93170c27d47017a81c4ef430823939fd0b9ec9c7
Merged-In: I93170c27d47017a81c4ef430823939fd0b9ec9c7
diff --git a/packages/SystemUI/shared/Android.bp b/packages/SystemUI/shared/Android.bp
index 114ea65..165f9eb 100644
--- a/packages/SystemUI/shared/Android.bp
+++ b/packages/SystemUI/shared/Android.bp
@@ -47,6 +47,7 @@
],
static_libs: [
"PluginCoreLib",
+ "SystemUIAnimationLib",
"SystemUIUnfoldLib",
"androidx.dynamicanimation_dynamicanimation",
"androidx.concurrent_concurrent-futures",
diff --git a/packages/SystemUI/shared/res/values/attrs.xml b/packages/SystemUI/shared/res/values/attrs.xml
new file mode 100644
index 0000000..f9d66ee
--- /dev/null
+++ b/packages/SystemUI/shared/res/values/attrs.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2022 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<!-- Formatting note: terminate all comments with a period, to avoid breaking
+ the documentation output. To suppress comment lines from the documentation
+ output, insert an eat-comment element after the comment lines.
+-->
+
+<resources>
+ <declare-styleable name="AnimatableClockView">
+ <attr name="dozeWeight" format="integer" />
+ <attr name="lockScreenWeight" format="integer" />
+ <attr name="chargeAnimationDelay" format="integer" />
+ </declare-styleable>
+</resources>
diff --git a/packages/SystemUI/shared/res/values/donottranslate.xml b/packages/SystemUI/shared/res/values/donottranslate.xml
new file mode 100644
index 0000000..383d5521
--- /dev/null
+++ b/packages/SystemUI/shared/res/values/donottranslate.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2022 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
+ <!-- Skeleton string format for displaying the time in 12-hour format. -->
+ <string name="clock_12hr_format">hm</string>
+
+ <!-- Skeleton string format for displaying the time in 24-hour format. -->
+ <string name="clock_24hr_format">Hm</string>
+</resources>
diff --git a/packages/SystemUI/shared/src/com/android/systemui/shared/clocks/AnimatableClockView.kt b/packages/SystemUI/shared/src/com/android/systemui/shared/clocks/AnimatableClockView.kt
new file mode 100644
index 0000000..5b1a23d
--- /dev/null
+++ b/packages/SystemUI/shared/src/com/android/systemui/shared/clocks/AnimatableClockView.kt
@@ -0,0 +1,399 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.systemui.shared.clocks
+
+import android.animation.TimeInterpolator
+import android.annotation.ColorInt
+import android.annotation.FloatRange
+import android.annotation.IntRange
+import android.annotation.SuppressLint
+import android.content.Context
+import android.graphics.Canvas
+import android.text.TextUtils
+import android.text.format.DateFormat
+import android.util.AttributeSet
+import android.widget.TextView
+import com.android.systemui.animation.GlyphCallback
+import com.android.systemui.animation.Interpolators
+import com.android.systemui.animation.TextAnimator
+import com.android.systemui.shared.R
+import java.io.PrintWriter
+import java.util.Calendar
+import java.util.Locale
+import java.util.TimeZone
+
+/**
+ * Displays the time with the hour positioned above the minutes. (ie: 09 above 30 is 9:30)
+ * The time's text color is a gradient that changes its colors based on its controller.
+ */
+@SuppressLint("AppCompatCustomView")
+class AnimatableClockView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0,
+ defStyleRes: Int = 0
+) : TextView(context, attrs, defStyleAttr, defStyleRes) {
+
+ private var lastMeasureCall: CharSequence = ""
+
+ private val time = Calendar.getInstance()
+
+ private val dozingWeightInternal: Int
+ private val lockScreenWeightInternal: Int
+ private val isSingleLineInternal: Boolean
+
+ private var format: CharSequence? = null
+ private var descFormat: CharSequence? = null
+
+ @ColorInt
+ private var dozingColor = 0
+
+ @ColorInt
+ private var lockScreenColor = 0
+
+ private var lineSpacingScale = 1f
+ private val chargeAnimationDelay: Int
+ private var textAnimator: TextAnimator? = null
+ private var onTextAnimatorInitialized: Runnable? = null
+
+ val dozingWeight: Int
+ get() = if (useBoldedVersion()) dozingWeightInternal + 100 else dozingWeightInternal
+
+ val lockScreenWeight: Int
+ get() = if (useBoldedVersion()) lockScreenWeightInternal + 100 else lockScreenWeightInternal
+
+ init {
+ val animatableClockViewAttributes = context.obtainStyledAttributes(
+ attrs, R.styleable.AnimatableClockView, defStyleAttr, defStyleRes
+ )
+
+ try {
+ dozingWeightInternal = animatableClockViewAttributes.getInt(
+ R.styleable.AnimatableClockView_dozeWeight,
+ 100
+ )
+ lockScreenWeightInternal = animatableClockViewAttributes.getInt(
+ R.styleable.AnimatableClockView_lockScreenWeight,
+ 300
+ )
+ chargeAnimationDelay = animatableClockViewAttributes.getInt(
+ R.styleable.AnimatableClockView_chargeAnimationDelay, 200
+ )
+ } finally {
+ animatableClockViewAttributes.recycle()
+ }
+
+ val textViewAttributes = context.obtainStyledAttributes(
+ attrs, android.R.styleable.TextView,
+ defStyleAttr, defStyleRes
+ )
+
+ isSingleLineInternal =
+ try {
+ textViewAttributes.getBoolean(android.R.styleable.TextView_singleLine, false)
+ } finally {
+ textViewAttributes.recycle()
+ }
+
+ refreshFormat()
+ }
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ refreshFormat()
+ }
+
+ /**
+ * Whether to use a bolded version based on the user specified fontWeightAdjustment.
+ */
+ fun useBoldedVersion(): Boolean {
+ // "Bold text" fontWeightAdjustment is 300.
+ return resources.configuration.fontWeightAdjustment > 100
+ }
+
+ fun refreshTime() {
+ time.timeInMillis = System.currentTimeMillis()
+ contentDescription = DateFormat.format(descFormat, time)
+ val formattedText = DateFormat.format(format, time)
+ // Setting text actually triggers a layout pass (because the text view is set to
+ // wrap_content width and TextView always relayouts for this). Avoid needless
+ // relayout if the text didn't actually change.
+ if (!TextUtils.equals(text, formattedText)) {
+ text = formattedText
+ }
+ }
+
+ fun onTimeZoneChanged(timeZone: TimeZone?) {
+ time.timeZone = timeZone
+ refreshFormat()
+ }
+
+ @SuppressLint("DrawAllocation")
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
+ lastMeasureCall = DateFormat.format(descFormat, System.currentTimeMillis())
+ val animator = textAnimator
+ if (animator == null) {
+ textAnimator = TextAnimator(layout) { invalidate() }
+ onTextAnimatorInitialized?.run()
+ onTextAnimatorInitialized = null
+ } else {
+ animator.updateLayout(layout)
+ }
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ // intentionally doesn't call super.onDraw here or else the text will be rendered twice
+ textAnimator?.draw(canvas)
+ }
+
+ fun setLineSpacingScale(scale: Float) {
+ lineSpacingScale = scale
+ setLineSpacing(0f, lineSpacingScale)
+ }
+
+ fun setColors(@ColorInt dozingColor: Int, lockScreenColor: Int) {
+ this.dozingColor = dozingColor
+ this.lockScreenColor = lockScreenColor
+ }
+
+ fun animateAppearOnLockscreen() {
+ if (textAnimator == null) {
+ return
+ }
+ setTextStyle(
+ weight = dozingWeight,
+ textSize = -1f,
+ color = lockScreenColor,
+ animate = false,
+ duration = 0,
+ delay = 0,
+ onAnimationEnd = null
+ )
+ setTextStyle(
+ weight = lockScreenWeight,
+ textSize = -1f,
+ color = lockScreenColor,
+ animate = true,
+ duration = APPEAR_ANIM_DURATION,
+ delay = 0,
+ onAnimationEnd = null
+ )
+ }
+
+ fun animateFoldAppear(animate: Boolean = true) {
+ if (textAnimator == null) {
+ return
+ }
+ setTextStyle(
+ weight = lockScreenWeightInternal,
+ textSize = -1f,
+ color = lockScreenColor,
+ animate = false,
+ duration = 0,
+ delay = 0,
+ onAnimationEnd = null
+ )
+ setTextStyle(
+ weight = dozingWeightInternal,
+ textSize = -1f,
+ color = dozingColor,
+ animate = animate,
+ interpolator = Interpolators.EMPHASIZED_DECELERATE,
+ duration = ANIMATION_DURATION_FOLD_TO_AOD.toLong(),
+ delay = 0,
+ onAnimationEnd = null
+ )
+ }
+
+ fun animateCharge(isDozing: () -> Boolean) {
+ if (textAnimator == null || textAnimator!!.isRunning()) {
+ // Skip charge animation if dozing animation is already playing.
+ return
+ }
+ val startAnimPhase2 = Runnable {
+ setTextStyle(
+ weight = if (isDozing()) dozingWeight else lockScreenWeight,
+ textSize = -1f,
+ color = null,
+ animate = true,
+ duration = CHARGE_ANIM_DURATION_PHASE_1,
+ delay = 0,
+ onAnimationEnd = null
+ )
+ }
+ setTextStyle(
+ weight = if (isDozing()) lockScreenWeight else dozingWeight,
+ textSize = -1f,
+ color = null,
+ animate = true,
+ duration = CHARGE_ANIM_DURATION_PHASE_0,
+ delay = chargeAnimationDelay.toLong(),
+ onAnimationEnd = startAnimPhase2
+ )
+ }
+
+ fun animateDoze(isDozing: Boolean, animate: Boolean) {
+ setTextStyle(
+ weight = if (isDozing) dozingWeight else lockScreenWeight,
+ textSize = -1f,
+ color = if (isDozing) dozingColor else lockScreenColor,
+ animate = animate,
+ duration = DOZE_ANIM_DURATION,
+ delay = 0,
+ onAnimationEnd = null
+ )
+ }
+
+ private val glyphFilter: GlyphCallback? = null // Add text animation tweak here.
+
+ /**
+ * Set text style with an optional animation.
+ *
+ * By passing -1 to weight, the view preserves its current weight.
+ * By passing -1 to textSize, the view preserves its current text size.
+ *
+ * @param weight text weight.
+ * @param textSize font size.
+ * @param animate true to animate the text style change, otherwise false.
+ */
+ private fun setTextStyle(
+ @IntRange(from = 0, to = 1000) weight: Int,
+ @FloatRange(from = 0.0) textSize: Float,
+ color: Int?,
+ animate: Boolean,
+ interpolator: TimeInterpolator?,
+ duration: Long,
+ delay: Long,
+ onAnimationEnd: Runnable?
+ ) {
+ if (textAnimator != null) {
+ textAnimator?.setTextStyle(
+ weight = weight,
+ textSize = textSize,
+ color = color,
+ animate = animate,
+ duration = duration,
+ interpolator = interpolator,
+ delay = delay,
+ onAnimationEnd = onAnimationEnd
+ )
+ textAnimator?.glyphFilter = glyphFilter
+ } else {
+ // when the text animator is set, update its start values
+ onTextAnimatorInitialized = Runnable {
+ textAnimator?.setTextStyle(
+ weight = weight,
+ textSize = textSize,
+ color = color,
+ animate = false,
+ duration = duration,
+ interpolator = interpolator,
+ delay = delay,
+ onAnimationEnd = onAnimationEnd
+ )
+ textAnimator?.glyphFilter = glyphFilter
+ }
+ }
+ }
+
+ private fun setTextStyle(
+ @IntRange(from = 0, to = 1000) weight: Int,
+ @FloatRange(from = 0.0) textSize: Float,
+ color: Int?,
+ animate: Boolean,
+ duration: Long,
+ delay: Long,
+ onAnimationEnd: Runnable?
+ ) {
+ setTextStyle(
+ weight = weight,
+ textSize = textSize,
+ color = color,
+ animate = animate,
+ interpolator = null,
+ duration = duration,
+ delay = delay,
+ onAnimationEnd = onAnimationEnd
+ )
+ }
+
+ fun refreshFormat() {
+ Patterns.update(context)
+ val use24HourFormat = DateFormat.is24HourFormat(context)
+
+ format = when {
+ isSingleLineInternal && use24HourFormat -> Patterns.sClockView24
+ !isSingleLineInternal && use24HourFormat -> DOUBLE_LINE_FORMAT_24_HOUR
+ isSingleLineInternal && !use24HourFormat -> Patterns.sClockView12
+ else -> DOUBLE_LINE_FORMAT_12_HOUR
+ }
+
+ descFormat = if (use24HourFormat) Patterns.sClockView24 else Patterns.sClockView12
+
+ refreshTime()
+ }
+
+ fun dump(pw: PrintWriter) {
+ pw.println("$this")
+ pw.println(" measuredWidth=$measuredWidth")
+ pw.println(" measuredHeight=$measuredHeight")
+ pw.println(" singleLineInternal=$isSingleLineInternal")
+ pw.println(" lastMeasureCall=$lastMeasureCall")
+ pw.println(" currText=$text")
+ pw.println(" currTimeContextDesc=$contentDescription")
+ pw.println(" time=$time")
+ }
+
+ // DateFormat.getBestDateTimePattern is extremely expensive, and refresh is called often.
+ // This is an optimization to ensure we only recompute the patterns when the inputs change.
+ private object Patterns {
+ var sClockView12: String? = null
+ var sClockView24: String? = null
+ var sCacheKey: String? = null
+
+ fun update(context: Context) {
+ val locale = Locale.getDefault()
+ val res = context.resources
+ val clockView12Skel = res.getString(R.string.clock_12hr_format)
+ val clockView24Skel = res.getString(R.string.clock_24hr_format)
+ val key = locale.toString() + clockView12Skel + clockView24Skel
+ if (key == sCacheKey) return
+
+ val clockView12 = DateFormat.getBestDateTimePattern(locale, clockView12Skel)
+ sClockView12 = clockView12
+
+ // CLDR insists on adding an AM/PM indicator even though it wasn't in the skeleton
+ // format. The following code removes the AM/PM indicator if we didn't want it.
+ if (!clockView12Skel.contains("a")) {
+ sClockView12 = clockView12.replace("a".toRegex(), "").trim { it <= ' ' }
+ }
+ sClockView24 = DateFormat.getBestDateTimePattern(locale, clockView24Skel)
+ sCacheKey = key
+ }
+ }
+
+ companion object {
+ private val TAG = AnimatableClockView::class.simpleName
+ const val ANIMATION_DURATION_FOLD_TO_AOD: Int = 600
+ private const val DOUBLE_LINE_FORMAT_12_HOUR = "hh\nmm"
+ private const val DOUBLE_LINE_FORMAT_24_HOUR = "HH\nmm"
+ private const val DOZE_ANIM_DURATION: Long = 300
+ private const val APPEAR_ANIM_DURATION: Long = 350
+ private const val CHARGE_ANIM_DURATION_PHASE_0: Long = 500
+ private const val CHARGE_ANIM_DURATION_PHASE_1: Long = 1000
+ }
+}