blob: 801a2d6170cc5daa714b4e61bc995f68fdb4f7c7 [file] [log] [blame]
/*
* 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.IntRange
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.text.Layout
import android.text.TextUtils
import android.text.format.DateFormat
import android.util.AttributeSet
import android.util.MathUtils.constrainedMap
import android.util.TypedValue.COMPLEX_UNIT_PX
import android.view.View
import android.view.View.MeasureSpec.EXACTLY
import android.widget.TextView
import com.android.app.animation.Interpolators
import com.android.internal.annotations.VisibleForTesting
import com.android.systemui.animation.GlyphCallback
import com.android.systemui.animation.TextAnimator
import com.android.systemui.animation.TypefaceVariantCacheImpl
import com.android.systemui.customization.R
import com.android.systemui.log.core.LogLevel
import com.android.systemui.log.core.LogcatOnlyMessageBuffer
import com.android.systemui.log.core.Logger
import com.android.systemui.log.core.MessageBuffer
import java.io.PrintWriter
import java.util.Calendar
import java.util.Locale
import java.util.TimeZone
import kotlin.math.min
/**
* 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) {
// To protect us from issues from this being null while the TextView constructor is running, we
// implement the get method and ensure a value is returned before initialization is complete.
private var logger = DEFAULT_LOGGER
get() = field ?: DEFAULT_LOGGER
var messageBuffer: MessageBuffer
get() = logger.buffer
set(value) {
logger = Logger(value, TAG)
}
var hasCustomPositionUpdatedAnimation: Boolean = false
var migratedClocks: Boolean = false
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: ((TextAnimator) -> Unit)? = null
private var translateForCenterAnimation = false
private val parentWidth: Int
get() = (parent as View).measuredWidth
// last text size which is not constrained by view height
private var lastUnconstrainedTextSize: Float = Float.MAX_VALUE
@VisibleForTesting
var textAnimatorFactory: (Layout, () -> Unit) -> TextAnimator = { layout, invalidateCb ->
val cache = TypefaceVariantCacheImpl(layout.paint.typeface, NUM_CLOCK_FONT_ANIMATION_STEPS)
TextAnimator(layout, cache, invalidateCb)
}
// Used by screenshot tests to provide stability
@VisibleForTesting var isAnimationEnabled: Boolean = true
@VisibleForTesting var timeOverrideInMillis: Long? = null
val dozingWeight: Int
get() = if (useBoldedVersion()) dozingWeightInternal + 100 else dozingWeightInternal
val lockScreenWeight: Int
get() = if (useBoldedVersion()) lockScreenWeightInternal + 100 else lockScreenWeightInternal
/**
* The number of pixels below the baseline. For fonts that support languages such as Burmese,
* this space can be significant and should be accounted for when computing layout.
*/
val bottom: Float
get() = paint?.fontMetrics?.bottom ?: 0f
init {
val animatableClockViewAttributes =
context.obtainStyledAttributes(
attrs,
R.styleable.AnimatableClockView,
defStyleAttr,
defStyleRes,
)
try {
dozingWeightInternal =
animatableClockViewAttributes.getInt(
R.styleable.AnimatableClockView_dozeWeight,
/* default = */ 100,
)
lockScreenWeightInternal =
animatableClockViewAttributes.getInt(
R.styleable.AnimatableClockView_lockScreenWeight,
/* default = */ 300,
)
chargeAnimationDelay =
animatableClockViewAttributes.getInt(
R.styleable.AnimatableClockView_chargeAnimationDelay,
/* default = */ 200,
)
} finally {
animatableClockViewAttributes.recycle()
}
val textViewAttributes =
context.obtainStyledAttributes(
attrs,
android.R.styleable.TextView,
defStyleAttr,
defStyleRes,
)
try {
isSingleLineInternal =
textViewAttributes.getBoolean(
android.R.styleable.TextView_singleLine,
/* default = */ false,
)
} finally {
textViewAttributes.recycle()
}
refreshFormat()
}
override fun onAttachedToWindow() {
logger.d("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 = timeOverrideInMillis ?: System.currentTimeMillis()
contentDescription = DateFormat.format(descFormat, time)
val formattedText = DateFormat.format(format, time)
logger.d({ "refreshTime: new formattedText=$str1" }) { str1 = formattedText?.toString() }
// Setting text actually triggers a layout pass in TextView (because the text view is set to
// wrap_content width and TextView always relayouts for this). This avoids needless relayout
// if the text didn't actually change.
if (TextUtils.equals(text, formattedText)) {
return
}
text = formattedText
logger.d({ "refreshTime: done setting new time text to: $str1" }) {
str1 = formattedText?.toString()
}
// Because the TextLayout may mutate under the hood as a result of the new text, we notify
// the TextAnimator that it may have changed and request a measure/layout. A crash will
// occur on the next invocation of setTextStyle if the layout is mutated without being
// notified TextInterpolator being notified.
if (layout != null) {
textAnimator?.updateLayout(layout)
logger.d("refreshTime: done updating textAnimator layout")
}
requestLayout()
logger.d("refreshTime: after requestLayout")
}
fun onTimeZoneChanged(timeZone: TimeZone?) {
logger.d({ "onTimeZoneChanged($str1)" }) { str1 = timeZone?.toString() }
time.timeZone = timeZone
refreshFormat()
}
override fun setTextSize(type: Int, size: Float) {
super.setTextSize(type, size)
lastUnconstrainedTextSize = if (type == COMPLEX_UNIT_PX) size else Float.MAX_VALUE
}
@SuppressLint("DrawAllocation")
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
logger.d("onMeasure")
if (
migratedClocks &&
!isSingleLineInternal &&
MeasureSpec.getMode(heightMeasureSpec) == EXACTLY
) {
// Call straight into TextView.setTextSize to avoid setting lastUnconstrainedTextSize
val size = min(lastUnconstrainedTextSize, MeasureSpec.getSize(heightMeasureSpec) / 2F)
super.setTextSize(COMPLEX_UNIT_PX, size)
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
textAnimator?.let { animator -> animator.updateLayout(layout, textSize) }
?: run {
textAnimator =
textAnimatorFactory(layout, ::invalidate).also {
onTextAnimatorInitialized?.invoke(it)
onTextAnimatorInitialized = null
}
}
if (migratedClocks && hasCustomPositionUpdatedAnimation) {
// Expand width to avoid clock being clipped during stepping animation
val targetWidth = measuredWidth + MeasureSpec.getSize(widthMeasureSpec) / 2
// This comparison is effectively a check if we're in splitshade or not
translateForCenterAnimation = parentWidth > targetWidth
if (translateForCenterAnimation) {
setMeasuredDimension(targetWidth, measuredHeight)
}
} else {
translateForCenterAnimation = false
}
}
override fun onDraw(canvas: Canvas) {
canvas.save()
if (translateForCenterAnimation) {
canvas.translate(parentWidth / 4f, 0f)
}
logger.d({ "onDraw($str1)" }) { str1 = text.toString() }
// intentionally doesn't call super.onDraw here or else the text will be rendered twice
textAnimator?.draw(canvas)
canvas.restore()
}
override fun invalidate() {
logger.d("invalidate")
super.invalidate()
}
override fun onTextChanged(
text: CharSequence,
start: Int,
lengthBefore: Int,
lengthAfter: Int,
) {
logger.d({ "onTextChanged($str1)" }) { str1 = text.toString() }
super.onTextChanged(text, start, lengthBefore, lengthAfter)
}
fun setLineSpacingScale(scale: Float) {
lineSpacingScale = scale
setLineSpacing(0f, lineSpacingScale)
}
fun setColors(@ColorInt dozingColor: Int, lockScreenColor: Int) {
this.dozingColor = dozingColor
this.lockScreenColor = lockScreenColor
}
fun animateColorChange() {
logger.d("animateColorChange")
setTextStyle(
weight = lockScreenWeight,
color = null, /* using current color */
animate = false,
interpolator = null,
duration = 0,
delay = 0,
onAnimationEnd = null,
)
setTextStyle(
weight = lockScreenWeight,
color = lockScreenColor,
animate = true,
interpolator = null,
duration = COLOR_ANIM_DURATION,
delay = 0,
onAnimationEnd = null,
)
}
fun animateAppearOnLockscreen() {
logger.d("animateAppearOnLockscreen")
setTextStyle(
weight = dozingWeight,
color = lockScreenColor,
animate = false,
interpolator = null,
duration = 0,
delay = 0,
onAnimationEnd = null,
)
setTextStyle(
weight = lockScreenWeight,
color = lockScreenColor,
animate = true,
duration = APPEAR_ANIM_DURATION,
interpolator = Interpolators.EMPHASIZED_DECELERATE,
delay = 0,
onAnimationEnd = null,
)
}
fun animateFoldAppear(animate: Boolean = true) {
if (textAnimator == null) {
return
}
logger.d("animateFoldAppear")
setTextStyle(
weight = lockScreenWeightInternal,
color = lockScreenColor,
animate = false,
interpolator = null,
duration = 0,
delay = 0,
onAnimationEnd = null,
)
setTextStyle(
weight = dozingWeightInternal,
color = dozingColor,
animate = animate,
interpolator = Interpolators.EMPHASIZED_DECELERATE,
duration = ANIMATION_DURATION_FOLD_TO_AOD.toLong(),
delay = 0,
onAnimationEnd = null,
)
}
fun animateCharge(isDozing: () -> Boolean) {
// Skip charge animation if dozing animation is already playing.
if (textAnimator == null || textAnimator!!.isRunning()) {
return
}
logger.d("animateCharge")
val startAnimPhase2 = Runnable {
setTextStyle(
weight = if (isDozing()) dozingWeight else lockScreenWeight,
color = null,
animate = true,
interpolator = null,
duration = CHARGE_ANIM_DURATION_PHASE_1,
delay = 0,
onAnimationEnd = null,
)
}
setTextStyle(
weight = if (isDozing()) lockScreenWeight else dozingWeight,
color = null,
animate = true,
interpolator = null,
duration = CHARGE_ANIM_DURATION_PHASE_0,
delay = chargeAnimationDelay.toLong(),
onAnimationEnd = startAnimPhase2,
)
}
fun animateDoze(isDozing: Boolean, animate: Boolean) {
logger.d("animateDoze")
setTextStyle(
weight = if (isDozing) dozingWeight else lockScreenWeight,
color = if (isDozing) dozingColor else lockScreenColor,
animate = animate,
interpolator = null,
duration = DOZE_ANIM_DURATION,
delay = 0,
onAnimationEnd = null,
)
}
// The offset of each glyph from where it should be.
private var glyphOffsets = mutableListOf(0.0f, 0.0f, 0.0f, 0.0f)
private var lastSeenAnimationProgress = 1.0f
// If the animation is being reversed, the target offset for each glyph for the "stop".
private var animationCancelStartPosition = mutableListOf(0.0f, 0.0f, 0.0f, 0.0f)
private var animationCancelStopPosition = 0.0f
// Whether the currently playing animation needed a stop (and thus, is shortened).
private var currentAnimationNeededStop = false
private val glyphFilter: GlyphCallback = { positionedGlyph, _ ->
val offset = positionedGlyph.lineNo * DIGITS_PER_LINE + positionedGlyph.glyphIndex
if (offset < glyphOffsets.size) {
positionedGlyph.x += glyphOffsets[offset]
}
}
/**
* 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.
* - By passing null to color, the view preserves its current color.
*
* @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,
color: Int?,
animate: Boolean,
interpolator: TimeInterpolator?,
duration: Long,
delay: Long,
onAnimationEnd: Runnable?,
) {
textAnimator?.let {
it.setTextStyle(
weight = weight,
color = color,
animate = animate && isAnimationEnabled,
duration = duration,
interpolator = interpolator,
delay = delay,
onAnimationEnd = onAnimationEnd,
)
it.glyphFilter = glyphFilter
}
?: run {
// when the text animator is set, update its start values
onTextAnimatorInitialized = { textAnimator ->
textAnimator.setTextStyle(
weight = weight,
color = color,
animate = false,
duration = duration,
interpolator = interpolator,
delay = delay,
onAnimationEnd = onAnimationEnd,
)
textAnimator.glyphFilter = glyphFilter
}
}
}
fun refreshFormat() = refreshFormat(DateFormat.is24HourFormat(context))
fun refreshFormat(use24HourFormat: Boolean) {
Patterns.update(context)
format =
when {
isSingleLineInternal && use24HourFormat -> Patterns.sClockView24
!isSingleLineInternal && use24HourFormat -> DOUBLE_LINE_FORMAT_24_HOUR
isSingleLineInternal && !use24HourFormat -> Patterns.sClockView12
else -> DOUBLE_LINE_FORMAT_12_HOUR
}
logger.d({ "refreshFormat($str1)" }) { str1 = format?.toString() }
descFormat = if (use24HourFormat) Patterns.sClockView24 else Patterns.sClockView12
refreshTime()
}
fun dump(pw: PrintWriter) {
pw.println("$this")
pw.println(" alpha=$alpha")
pw.println(" measuredWidth=$measuredWidth")
pw.println(" measuredHeight=$measuredHeight")
pw.println(" singleLineInternal=$isSingleLineInternal")
pw.println(" currText=$text")
pw.println(" currTimeContextDesc=$contentDescription")
pw.println(" dozingWeightInternal=$dozingWeightInternal")
pw.println(" lockScreenWeightInternal=$lockScreenWeightInternal")
pw.println(" dozingColor=$dozingColor")
pw.println(" lockScreenColor=$lockScreenColor")
pw.println(" time=$time")
}
private val moveToCenterDelays: List<Int>
get() = if (isLayoutRtl) MOVE_LEFT_DELAYS else MOVE_RIGHT_DELAYS
private val moveToSideDelays: List<Int>
get() = if (isLayoutRtl) MOVE_RIGHT_DELAYS else MOVE_LEFT_DELAYS
/**
* Offsets the glyphs of the clock for the step clock animation.
*
* The animation makes the glyphs of the clock move at different speeds, when the clock is
* moving horizontally.
*
* @param clockStartLeft the [getLeft] position of the clock, before it started moving.
* @param clockMoveDirection the direction in which it is moving. A positive number means right,
* and negative means left.
* @param moveFraction fraction of the clock movement. 0 means it is at the beginning, and 1
* means it finished moving.
*/
fun offsetGlyphsForStepClockAnimation(
clockStartLeft: Int,
clockMoveDirection: Int,
moveFraction: Float,
) {
val isMovingToCenter = if (isLayoutRtl) clockMoveDirection < 0 else clockMoveDirection > 0
// The sign of moveAmountDeltaForDigit is already set here
// we can interpret (left - clockStartLeft) as (destinationPosition - originPosition)
// so we no longer need to multiply direct sign to moveAmountDeltaForDigit
val currentMoveAmount = left - clockStartLeft
for (i in 0 until NUM_DIGITS) {
val digitFraction =
getDigitFraction(
digit = i,
isMovingToCenter = isMovingToCenter,
fraction = moveFraction,
)
val moveAmountForDigit = currentMoveAmount * digitFraction
val moveAmountDeltaForDigit = moveAmountForDigit - currentMoveAmount
glyphOffsets[i] = moveAmountDeltaForDigit
}
invalidate()
}
/**
* Offsets the glyphs of the clock for the step clock animation.
*
* The animation makes the glyphs of the clock move at different speeds, when the clock is
* moving horizontally. This method uses direction, distance, and fraction to determine offset.
*
* @param distance is the total distance in pixels to offset the glyphs when animation
* completes. Negative distance means we are animating the position towards the center.
* @param fraction fraction of the clock movement. 0 means it is at the beginning, and 1 means
* it finished moving.
*/
fun offsetGlyphsForStepClockAnimation(distance: Float, fraction: Float) {
for (i in 0 until NUM_DIGITS) {
val dir = if (isLayoutRtl) -1 else 1
val digitFraction =
getDigitFraction(digit = i, isMovingToCenter = distance > 0, fraction = fraction)
val moveAmountForDigit = dir * distance * digitFraction
glyphOffsets[i] = moveAmountForDigit
if (distance > 0) {
// If distance > 0 then we are moving from the left towards the center. We need to
// ensure that the glyphs are offset to the initial position.
glyphOffsets[i] -= dir * distance
}
}
invalidate()
}
override fun onRtlPropertiesChanged(layoutDirection: Int) {
if (migratedClocks) {
if (layoutDirection == LAYOUT_DIRECTION_RTL) {
textAlignment = TEXT_ALIGNMENT_TEXT_END
} else {
textAlignment = TEXT_ALIGNMENT_TEXT_START
}
}
super.onRtlPropertiesChanged(layoutDirection)
}
private fun getDigitFraction(digit: Int, isMovingToCenter: Boolean, fraction: Float): Float {
// The delay for the digit, in terms of fraction.
// (i.e. the digit should not move during 0.0 - 0.1).
val delays = if (isMovingToCenter) moveToCenterDelays else moveToSideDelays
val digitInitialDelay = delays[digit] * MOVE_DIGIT_STEP
return MOVE_INTERPOLATOR.getInterpolation(
constrainedMap(
/* rangeMin= */ 0.0f,
/* rangeMax= */ 1.0f,
/* valueMin= */ digitInitialDelay,
/* valueMax= */ digitInitialDelay + AVAILABLE_ANIMATION_TIME,
/* value= */ fraction,
)
)
}
/**
* DateFormat.getBestDateTimePattern is extremely expensive, and refresh is called often. This
* is a cache 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 clockView12Skel = context.resources.getString(R.string.clock_12hr_format)
val clockView24Skel = context.resources.getString(R.string.clock_24hr_format)
val key = "$locale$clockView12Skel$clockView24Skel"
if (key == sCacheKey) {
return
}
sClockView12 =
DateFormat.getBestDateTimePattern(locale, clockView12Skel).let {
// CLDR insists on adding an AM/PM indicator even though it wasn't in the format
// string. The following code removes the AM/PM indicator if we didn't want it.
if (!clockView12Skel.contains("a")) {
it.replace("a".toRegex(), "").trim { it <= ' ' }
} else it
}
sClockView24 = DateFormat.getBestDateTimePattern(locale, clockView24Skel)
sCacheKey = key
}
}
companion object {
private val TAG = AnimatableClockView::class.simpleName!!
private val DEFAULT_LOGGER = Logger(LogcatOnlyMessageBuffer(LogLevel.WARNING), TAG)
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 = 833
private const val CHARGE_ANIM_DURATION_PHASE_0: Long = 500
private const val CHARGE_ANIM_DURATION_PHASE_1: Long = 1000
private const val COLOR_ANIM_DURATION: Long = 400
private const val NUM_CLOCK_FONT_ANIMATION_STEPS = 30
// Constants for the animation
private val MOVE_INTERPOLATOR = Interpolators.EMPHASIZED
// Calculate the positions of all of the digits...
// Offset each digit by, say, 0.1
// This means that each digit needs to move over a slice of "fractions", i.e. digit 0 should
// move from 0.0 - 0.7, digit 1 from 0.1 - 0.8, digit 2 from 0.2 - 0.9, and digit 3
// from 0.3 - 1.0.
private const val NUM_DIGITS = 4
private const val DIGITS_PER_LINE = 2
// Delays. Each digit's animation should have a slight delay, so we get a nice
// "stepping" effect. When moving right, the second digit of the hour should move first.
// When moving left, the first digit of the hour should move first. The lists encode
// the delay for each digit (hour[0], hour[1], minute[0], minute[1]), to be multiplied
// by delayMultiplier.
private val MOVE_LEFT_DELAYS = listOf(0, 1, 2, 3)
private val MOVE_RIGHT_DELAYS = listOf(1, 0, 3, 2)
// How much delay to apply to each subsequent digit. This is measured in terms of "fraction"
// (i.e. a value of 0.1 would cause a digit to wait until fraction had hit 0.1, or 0.2 etc
// before moving).
//
// The current specs dictate that each digit should have a 33ms gap between them. The
// overall time is 1s right now.
private const val MOVE_DIGIT_STEP = 0.033f
// Total available transition time for each digit, taking into account the step. If step is
// 0.1, then digit 0 would animate over 0.0 - 0.7, making availableTime 0.7.
private const val AVAILABLE_ANIMATION_TIME = 1.0f - MOVE_DIGIT_STEP * (NUM_DIGITS - 1)
}
}