1 /* <lambda>null2 * Copyright (C) 2024 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 17 package com.android.systemui.shared.clocks.view 18 19 import android.graphics.Canvas 20 import android.icu.text.NumberFormat 21 import android.util.MathUtils.constrainedMap 22 import android.view.View 23 import android.view.ViewGroup 24 import android.widget.RelativeLayout 25 import androidx.annotation.VisibleForTesting 26 import androidx.core.view.children 27 import com.android.app.animation.Interpolators 28 import com.android.systemui.customization.R 29 import com.android.systemui.plugins.clocks.ClockAxisStyle 30 import com.android.systemui.plugins.clocks.ClockLogger 31 import com.android.systemui.plugins.clocks.VPoint 32 import com.android.systemui.plugins.clocks.VPointF 33 import com.android.systemui.plugins.clocks.VPointF.Companion.max 34 import com.android.systemui.plugins.clocks.VPointF.Companion.times 35 import com.android.systemui.plugins.clocks.VRectF 36 import com.android.systemui.shared.clocks.CanvasUtil.translate 37 import com.android.systemui.shared.clocks.CanvasUtil.use 38 import com.android.systemui.shared.clocks.ClockContext 39 import com.android.systemui.shared.clocks.DigitTranslateAnimator 40 import com.android.systemui.shared.clocks.ViewUtils.measuredSize 41 import java.util.Locale 42 import kotlin.collections.filterNotNull 43 import kotlin.collections.map 44 import kotlin.math.abs 45 import kotlin.math.max 46 import kotlin.math.min 47 import kotlin.math.roundToInt 48 49 fun clamp(value: Float, minVal: Float, maxVal: Float): Float = max(min(value, maxVal), minVal) 50 51 class FlexClockView(clockCtx: ClockContext) : ViewGroup(clockCtx.context) { 52 protected val logger = ClockLogger(this, clockCtx.messageBuffer, this::class.simpleName!!) 53 get() = field ?: ClockLogger.INIT_LOGGER 54 55 @VisibleForTesting 56 var isAnimationEnabled = true 57 set(value) { 58 field = value 59 childViews.forEach { view -> view.isAnimationEnabled = value } 60 } 61 62 var dozeFraction: Float = 0F 63 set(value) { 64 field = value 65 childViews.forEach { view -> view.dozeFraction = field } 66 } 67 68 var isReactiveTouchInteractionEnabled = false 69 set(value) { 70 field = value 71 } 72 73 var _childViews: List<SimpleDigitalClockTextView>? = null 74 val childViews: List<SimpleDigitalClockTextView> 75 get() { 76 return _childViews 77 ?: this.children 78 .map { child -> child as? SimpleDigitalClockTextView } 79 .filterNotNull() 80 .toList() 81 .also { _childViews = it } 82 } 83 84 private var maxChildSize = VPointF(-1, -1) 85 private val lockscreenTranslate = VPointF.ZERO 86 private var aodTranslate = VPointF.ZERO 87 88 private var onAnimateDoze: (() -> Unit)? = null 89 private var isDozeReadyToAnimate = false 90 91 // Does the current language have mono vertical size when displaying numerals 92 private var isMonoVerticalNumericLineSpacing = true 93 94 init { 95 setWillNotDraw(false) 96 layoutParams = 97 RelativeLayout.LayoutParams( 98 ViewGroup.LayoutParams.WRAP_CONTENT, 99 ViewGroup.LayoutParams.WRAP_CONTENT, 100 ) 101 updateLocale(Locale.getDefault()) 102 } 103 104 var onViewBoundsChanged: ((VRectF) -> Unit)? = null 105 private val digitOffsets = mutableMapOf<Int, Float>() 106 107 protected fun calculateSize( 108 widthMeasureSpec: Int, 109 heightMeasureSpec: Int, 110 shouldMeasureChildren: Boolean, 111 ): VPointF { 112 maxChildSize = VPointF(-1, -1) 113 childViews.forEach { textView -> 114 if (shouldMeasureChildren) { 115 textView.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED) 116 } 117 maxChildSize = max(maxChildSize, textView.measuredSize) 118 } 119 aodTranslate = VPointF.ZERO 120 // TODO(b/364680879): Cleanup 121 /* 122 aodTranslate = VPointF( 123 maxChildSize.x * AOD_HORIZONTAL_TRANSLATE_RATIO, 124 maxChildSize.y * AOD_VERTICAL_TRANSLATE_RATIO 125 ) 126 */ 127 128 val xScale = if (childViews.size < 4) 1f else 2f 129 val yBuffer = context.resources.getDimensionPixelSize(R.dimen.clock_vertical_digit_buffer) 130 return (maxChildSize + aodTranslate.abs()) * VPointF(xScale, 2f) + VPointF(0f, yBuffer) 131 } 132 133 override fun onViewAdded(child: View?) { 134 if (child == null) return 135 logger.onViewAdded(child) 136 super.onViewAdded(child) 137 (child as? SimpleDigitalClockTextView)?.let { 138 it.digitTranslateAnimator = DigitTranslateAnimator { invalidate() } 139 } 140 child.setWillNotDraw(true) 141 _childViews = null 142 } 143 144 override fun onViewRemoved(child: View?) { 145 super.onViewRemoved(child) 146 _childViews = null 147 } 148 149 fun refreshTime() { 150 logger.refreshTime() 151 childViews.forEach { textView -> textView.refreshText() } 152 } 153 154 override fun setVisibility(visibility: Int) { 155 logger.setVisibility(visibility) 156 super.setVisibility(visibility) 157 } 158 159 override fun setAlpha(alpha: Float) { 160 logger.setAlpha(alpha) 161 super.setAlpha(alpha) 162 } 163 164 override fun invalidate() { 165 logger.invalidate() 166 super.invalidate() 167 } 168 169 override fun requestLayout() { 170 logger.requestLayout() 171 super.requestLayout() 172 } 173 174 fun updateMeasuredSize() = 175 updateMeasuredSize( 176 measuredWidthAndState, 177 measuredHeightAndState, 178 shouldMeasureChildren = false, 179 ) 180 181 private fun updateMeasuredSize( 182 widthMeasureSpec: Int, 183 heightMeasureSpec: Int, 184 shouldMeasureChildren: Boolean, 185 ) { 186 val size = calculateSize(widthMeasureSpec, heightMeasureSpec, shouldMeasureChildren) 187 setMeasuredDimension(size.x.roundToInt(), size.y.roundToInt()) 188 } 189 190 fun updateLocation() { 191 val layoutBounds = this.layoutBounds ?: return 192 val bounds = VRectF.fromCenter(layoutBounds.center, this.measuredSize) 193 setFrame( 194 bounds.left.roundToInt(), 195 bounds.top.roundToInt(), 196 bounds.right.roundToInt(), 197 bounds.bottom.roundToInt(), 198 ) 199 updateChildFrames(isLayout = false) 200 onViewBoundsChanged?.let { it(bounds) } 201 } 202 203 override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { 204 logger.onMeasure(widthMeasureSpec, heightMeasureSpec) 205 updateMeasuredSize(widthMeasureSpec, heightMeasureSpec, shouldMeasureChildren = true) 206 207 isDozeReadyToAnimate = true 208 onAnimateDoze?.invoke() 209 onAnimateDoze = null 210 } 211 212 private var layoutBounds = VRectF.ZERO 213 214 override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { 215 logger.onLayout(changed, left, top, right, bottom) 216 layoutBounds = VRectF(left.toFloat(), top.toFloat(), right.toFloat(), bottom.toFloat()) 217 updateChildFrames(isLayout = true) 218 } 219 220 private fun updateChildFrames(isLayout: Boolean) { 221 val yBuffer = context.resources.getDimensionPixelSize(R.dimen.clock_vertical_digit_buffer) 222 childViews.forEach { child -> 223 var offset = 224 maxChildSize.run { 225 when (child.id) { 226 R.id.HOUR_FIRST_DIGIT -> VPointF.ZERO 227 R.id.HOUR_SECOND_DIGIT -> VPointF(x, 0f) 228 R.id.HOUR_DIGIT_PAIR -> VPointF.ZERO 229 // Add a small vertical buffer for second line views 230 R.id.MINUTE_DIGIT_PAIR -> VPointF(0f, y + yBuffer) 231 R.id.MINUTE_FIRST_DIGIT -> VPointF(0f, y + yBuffer) 232 R.id.MINUTE_SECOND_DIGIT -> VPointF(x, y + yBuffer) 233 else -> VPointF.ZERO 234 } 235 } 236 237 val childSize = child.measuredSize 238 offset += aodTranslate.abs() 239 240 // Horizontal offset to center each view in the available space 241 val midX = if (childViews.size < 4) measuredWidth / 2f else measuredWidth / 4f 242 offset += VPointF(midX - childSize.x / 2f, 0f) 243 244 val setPos = if (isLayout) child::layout else child::setLeftTopRightBottom 245 setPos( 246 offset.x.roundToInt(), 247 offset.y.roundToInt(), 248 (offset.x + childSize.x).roundToInt(), 249 (offset.y + childSize.y).roundToInt(), 250 ) 251 } 252 } 253 254 override fun onDraw(canvas: Canvas) { 255 logger.onDraw() 256 childViews.forEach { child -> 257 canvas.use { canvas -> 258 canvas.translate(digitOffsets.getOrDefault(child.id, 0f), 0f) 259 canvas.translate(child.left.toFloat(), child.top.toFloat()) 260 child.draw(canvas) 261 } 262 } 263 } 264 265 fun onLocaleChanged(locale: Locale) { 266 updateLocale(locale) 267 requestLayout() 268 } 269 270 fun updateColor(color: Int) { 271 childViews.forEach { view -> view.updateColor(color) } 272 invalidate() 273 } 274 275 fun updateAxes(axes: ClockAxisStyle, isAnimated: Boolean) { 276 childViews.forEach { view -> view.updateAxes(axes, isAnimated) } 277 requestLayout() 278 } 279 280 fun onFontSettingChanged(fontSizePx: Float) { 281 childViews.forEach { view -> view.applyTextSize(fontSizePx) } 282 } 283 284 fun animateDoze(isDozing: Boolean, isAnimated: Boolean) { 285 fun executeDozeAnimation() { 286 childViews.forEach { view -> view.animateDoze(isDozing, isAnimated) } 287 if (maxChildSize.x < 0 || maxChildSize.y < 0) { 288 measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED) 289 } 290 childViews.forEach { textView -> 291 textView.digitTranslateAnimator?.let { 292 if (!isDozing) { 293 it.animatePosition( 294 animate = isAnimated && isAnimationEnabled, 295 interpolator = Interpolators.EMPHASIZED, 296 duration = AOD_TRANSITION_DURATION, 297 targetTranslation = 298 updateDirectionalTargetTranslate(id, lockscreenTranslate), 299 ) 300 } else { 301 it.animatePosition( 302 animate = isAnimated && isAnimationEnabled, 303 interpolator = Interpolators.EMPHASIZED, 304 duration = AOD_TRANSITION_DURATION, 305 onAnimationEnd = null, 306 targetTranslation = updateDirectionalTargetTranslate(id, aodTranslate), 307 ) 308 } 309 } 310 } 311 } 312 313 if (isDozeReadyToAnimate) executeDozeAnimation() 314 else onAnimateDoze = { executeDozeAnimation() } 315 } 316 317 fun animateCharge() { 318 childViews.forEach { view -> view.animateCharge() } 319 childViews.forEach { textView -> 320 textView.digitTranslateAnimator?.let { 321 it.animatePosition( 322 animate = isAnimationEnabled, 323 interpolator = Interpolators.EMPHASIZED, 324 duration = CHARGING_TRANSITION_DURATION, 325 onAnimationEnd = { 326 it.animatePosition( 327 animate = isAnimationEnabled, 328 interpolator = Interpolators.EMPHASIZED, 329 duration = CHARGING_TRANSITION_DURATION, 330 targetTranslation = 331 updateDirectionalTargetTranslate( 332 textView.id, 333 if (dozeFraction == 1F) aodTranslate else lockscreenTranslate, 334 ), 335 ) 336 }, 337 targetTranslation = 338 updateDirectionalTargetTranslate( 339 textView.id, 340 if (dozeFraction == 1F) lockscreenTranslate else aodTranslate, 341 ), 342 ) 343 } 344 } 345 } 346 347 fun animateFidget(x: Float, y: Float) { 348 val touchPt = VPointF(x, y) 349 val ints = intArrayOf(0, 0) 350 childViews 351 .sortedBy { view -> 352 view.getLocationInWindow(ints) 353 val loc = VPoint(ints[0], ints[1]) 354 val center = loc + view.measuredSize / 2f 355 (center - touchPt).length() 356 } 357 .forEachIndexed { i, view -> 358 view.animateFidget(FIDGET_DELAYS[min(i, FIDGET_DELAYS.size - 1)]) 359 } 360 } 361 362 private fun updateLocale(locale: Locale) { 363 isMonoVerticalNumericLineSpacing = 364 !NON_MONO_VERTICAL_NUMERIC_LINE_SPACING_LANGUAGES.any { 365 val newLocaleNumberFormat = 366 NumberFormat.getInstance(locale).format(FORMAT_NUMBER.toLong()) 367 val nonMonoVerticalNumericLineSpaceNumberFormat = 368 NumberFormat.getInstance(Locale.forLanguageTag(it)) 369 .format(FORMAT_NUMBER.toLong()) 370 newLocaleNumberFormat == nonMonoVerticalNumericLineSpaceNumberFormat 371 } 372 } 373 374 /** 375 * Offsets the textViews of the clock for the step clock animation. 376 * 377 * The animation makes the textViews of the clock move at different speeds, when the clock is 378 * moving horizontally. 379 * 380 * @param clockStartLeft the [getLeft] position of the clock, before it started moving. 381 * @param clockMoveDirection the direction in which it is moving. A positive number means right, 382 * and negative means left. 383 * @param moveFraction fraction of the clock movement. 0 means it is at the beginning, and 1 384 * means it finished moving. 385 */ 386 fun offsetGlyphsForStepClockAnimation( 387 clockStartLeft: Int, 388 clockMoveDirection: Int, 389 moveFraction: Float, 390 ) { 391 // TODO(b/393577936): The step animation isn't correct with the two pairs approach 392 val isMovingToCenter = if (isLayoutRtl) clockMoveDirection < 0 else clockMoveDirection > 0 393 // The sign of moveAmountDeltaForDigit is already set here 394 // we can interpret (left - clockStartLeft) as (destinationPosition - originPosition) 395 // so we no longer need to multiply direct sign to moveAmountDeltaForDigit 396 val currentMoveAmount = left - clockStartLeft 397 var index = 0 398 childViews.forEach { child -> 399 val digitFraction = 400 getDigitFraction( 401 digit = index++, 402 isMovingToCenter = isMovingToCenter, 403 fraction = moveFraction, 404 ) 405 // left here is the final left position after the animation is done 406 val moveAmountForDigit = currentMoveAmount * digitFraction 407 var moveAmountDeltaForDigit = moveAmountForDigit - currentMoveAmount 408 if (isMovingToCenter && moveAmountForDigit < 0) moveAmountDeltaForDigit *= -1 409 digitOffsets[child.id] = moveAmountDeltaForDigit 410 invalidate() 411 } 412 } 413 414 private val moveToCenterDelays: List<Int> 415 get() = if (isLayoutRtl) MOVE_LEFT_DELAYS else MOVE_RIGHT_DELAYS 416 417 private val moveToSideDelays: List<Int> 418 get() = if (isLayoutRtl) MOVE_RIGHT_DELAYS else MOVE_LEFT_DELAYS 419 420 private fun getDigitFraction(digit: Int, isMovingToCenter: Boolean, fraction: Float): Float { 421 // The delay for the digit, in terms of fraction. 422 // (i.e. the digit should not move during 0.0 - 0.1). 423 val delays = if (isMovingToCenter) moveToCenterDelays else moveToSideDelays 424 val digitInitialDelay = delays[digit] * MOVE_DIGIT_STEP 425 return MOVE_INTERPOLATOR.getInterpolation( 426 constrainedMap( 427 /* rangeMin= */ 0.0f, 428 /* rangeMax= */ 1.0f, 429 /* valueMin= */ digitInitialDelay, 430 /* valueMax= */ digitInitialDelay + availableAnimationTime(childViews.size), 431 /* value= */ fraction, 432 ) 433 ) 434 } 435 436 companion object { 437 val AOD_TRANSITION_DURATION = 750L 438 val CHARGING_TRANSITION_DURATION = 300L 439 440 val AOD_HORIZONTAL_TRANSLATE_RATIO = -0.15F 441 val AOD_VERTICAL_TRANSLATE_RATIO = 0.075F 442 443 val FIDGET_DELAYS = listOf(0L, 75L, 150L, 225L) 444 445 // Delays. Each digit's animation should have a slight delay, so we get a nice 446 // "stepping" effect. When moving right, the second digit of the hour should move first. 447 // When moving left, the first digit of the hour should move first. The lists encode 448 // the delay for each digit (hour[0], hour[1], minute[0], minute[1]), to be multiplied 449 // by delayMultiplier. 450 private val MOVE_LEFT_DELAYS = listOf(0, 1, 2, 3) 451 private val MOVE_RIGHT_DELAYS = listOf(1, 0, 3, 2) 452 453 // How much delay to apply to each subsequent digit. This is measured in terms of "fraction" 454 // (i.e. a value of 0.1 would cause a digit to wait until fraction had hit 0.1, or 0.2 etc 455 // before moving). 456 // 457 // The current specs dictate that each digit should have a 33ms gap between them. The 458 // overall time is 1s right now. 459 private const val MOVE_DIGIT_STEP = 0.033f 460 461 // Constants for the animation 462 private val MOVE_INTERPOLATOR = Interpolators.EMPHASIZED 463 464 private const val FORMAT_NUMBER = 1234567890 465 466 // Total available transition time for each digit, taking into account the step. If step is 467 // 0.1, then digit 0 would animate over 0.0 - 0.7, making availableTime 0.7. 468 private fun availableAnimationTime(numDigits: Int): Float { 469 return 1.0f - MOVE_DIGIT_STEP * (numDigits.toFloat() - 1) 470 } 471 472 // Add language tags below that do not have vertically mono spaced numerals 473 private val NON_MONO_VERTICAL_NUMERIC_LINE_SPACING_LANGUAGES = 474 setOf( 475 "my" // Burmese 476 ) 477 478 // Use the sign of targetTranslation to control the direction of digit translation 479 fun updateDirectionalTargetTranslate(id: Int, targetTranslation: VPointF): VPointF { 480 return targetTranslation * 481 when (id) { 482 R.id.HOUR_FIRST_DIGIT -> VPointF(-1, -1) 483 R.id.HOUR_SECOND_DIGIT -> VPointF(1, -1) 484 R.id.MINUTE_FIRST_DIGIT -> VPointF(-1, 1) 485 R.id.MINUTE_SECOND_DIGIT -> VPointF(1, 1) 486 R.id.HOUR_DIGIT_PAIR -> VPointF(-1, -1) 487 R.id.MINUTE_DIGIT_PAIR -> VPointF(-1, 1) 488 else -> VPointF(1, 1) 489 } 490 } 491 } 492 } 493