1 package com.android.systemui.statusbar.notification 2 3 import android.util.FloatProperty 4 import android.view.View 5 import androidx.annotation.FloatRange 6 import com.android.systemui.R 7 import com.android.systemui.flags.FeatureFlags 8 import com.android.systemui.flags.Flags 9 import com.android.systemui.flags.ViewRefactorFlag 10 import com.android.systemui.statusbar.notification.stack.AnimationProperties 11 import com.android.systemui.statusbar.notification.stack.StackStateAnimator 12 import kotlin.math.abs 13 14 /** 15 * Interface that allows to request/retrieve top and bottom roundness (a value between 0f and 1f). 16 * 17 * To request a roundness value, an [SourceType] must be specified. In case more origins require 18 * different roundness, for the same property, the maximum value will always be chosen. 19 * 20 * It also returns the current radius for all corners ([updatedRadii]). 21 */ 22 interface Roundable { 23 /** Properties required for a Roundable */ 24 val roundableState: RoundableState 25 26 val clipHeight: Int 27 28 /** Current top roundness */ 29 @get:FloatRange(from = 0.0, to = 1.0) 30 @JvmDefault 31 val topRoundness: Float 32 get() = roundableState.topRoundness 33 34 /** Current bottom roundness */ 35 @get:FloatRange(from = 0.0, to = 1.0) 36 @JvmDefault 37 val bottomRoundness: Float 38 get() = roundableState.bottomRoundness 39 40 /** Max radius in pixel */ 41 @JvmDefault 42 val maxRadius: Float 43 get() = roundableState.maxRadius 44 45 /** Current top corner in pixel, based on [topRoundness] and [maxRadius] */ 46 @JvmDefault 47 val topCornerRadius: Float 48 get() = 49 if (roundableState.newHeadsUpAnim.isEnabled) roundableState.topCornerRadius 50 else topRoundness * maxRadius 51 52 /** Current bottom corner in pixel, based on [bottomRoundness] and [maxRadius] */ 53 @JvmDefault 54 val bottomCornerRadius: Float 55 get() = 56 if (roundableState.newHeadsUpAnim.isEnabled) roundableState.bottomCornerRadius 57 else bottomRoundness * maxRadius 58 59 /** Get and update the current radii */ 60 @JvmDefault 61 val updatedRadii: FloatArray 62 get() = 63 roundableState.radiiBuffer.also { radii -> 64 updateRadii( 65 topCornerRadius = topCornerRadius, 66 bottomCornerRadius = bottomCornerRadius, 67 radii = radii, 68 ) 69 } 70 71 /** 72 * Request the top roundness [value] for a specific [sourceType]. 73 * 74 * The top roundness of a [Roundable] can be defined by different [sourceType]. In case more 75 * origins require different roundness, for the same property, the maximum value will always be 76 * chosen. 77 * 78 * @param value a value between 0f and 1f. 79 * @param animate true if it should animate to that value. 80 * @param sourceType the source from which the request for roundness comes. 81 * @return Whether the roundness was changed. 82 */ 83 @JvmDefault 84 fun requestTopRoundness( 85 @FloatRange(from = 0.0, to = 1.0) value: Float, 86 sourceType: SourceType, 87 animate: Boolean, 88 ): Boolean { 89 val roundnessMap = roundableState.topRoundnessMap 90 val lastValue = roundnessMap.values.maxOrNull() ?: 0f 91 if (value == 0f) { 92 // we should only take the largest value, and since the smallest value is 0f, we can 93 // remove this value from the list. In the worst case, the list is empty and the 94 // default value is 0f. 95 roundnessMap.remove(sourceType) 96 } else { 97 roundnessMap[sourceType] = value 98 } 99 val newValue = roundnessMap.values.maxOrNull() ?: 0f 100 101 if (lastValue != newValue) { 102 val wasAnimating = roundableState.isTopAnimating() 103 104 // Fail safe: 105 // when we've been animating previously and we're now getting an update in the 106 // other direction, make sure to animate it too, otherwise, the localized updating 107 // may make the start larger than 1.0. 108 val shouldAnimate = wasAnimating && abs(newValue - lastValue) > 0.5f 109 110 roundableState.setTopRoundness(value = newValue, animated = shouldAnimate || animate) 111 return true 112 } 113 return false 114 } 115 116 /** 117 * Request the top roundness [value] for a specific [sourceType]. Animate the roundness if the 118 * view is shown. 119 * 120 * The top roundness of a [Roundable] can be defined by different [sourceType]. In case more 121 * origins require different roundness, for the same property, the maximum value will always be 122 * chosen. 123 * 124 * @param value a value between 0f and 1f. 125 * @param sourceType the source from which the request for roundness comes. 126 * @return Whether the roundness was changed. 127 */ 128 @JvmDefault 129 fun requestTopRoundness( 130 @FloatRange(from = 0.0, to = 1.0) value: Float, 131 sourceType: SourceType, 132 ): Boolean { 133 return requestTopRoundness( 134 value = value, 135 sourceType = sourceType, 136 animate = roundableState.targetView.isShown 137 ) 138 } 139 140 /** 141 * Request the bottom roundness [value] for a specific [sourceType]. 142 * 143 * The bottom roundness of a [Roundable] can be defined by different [sourceType]. In case more 144 * origins require different roundness, for the same property, the maximum value will always be 145 * chosen. 146 * 147 * @param value value between 0f and 1f. 148 * @param animate true if it should animate to that value. 149 * @param sourceType the source from which the request for roundness comes. 150 * @return Whether the roundness was changed. 151 */ 152 @JvmDefault 153 fun requestBottomRoundness( 154 @FloatRange(from = 0.0, to = 1.0) value: Float, 155 sourceType: SourceType, 156 animate: Boolean, 157 ): Boolean { 158 val roundnessMap = roundableState.bottomRoundnessMap 159 val lastValue = roundnessMap.values.maxOrNull() ?: 0f 160 if (value == 0f) { 161 // we should only take the largest value, and since the smallest value is 0f, we can 162 // remove this value from the list. In the worst case, the list is empty and the 163 // default value is 0f. 164 roundnessMap.remove(sourceType) 165 } else { 166 roundnessMap[sourceType] = value 167 } 168 val newValue = roundnessMap.values.maxOrNull() ?: 0f 169 170 if (lastValue != newValue) { 171 val wasAnimating = roundableState.isBottomAnimating() 172 173 // Fail safe: 174 // when we've been animating previously and we're now getting an update in the 175 // other direction, make sure to animate it too, otherwise, the localized updating 176 // may make the start larger than 1.0. 177 val shouldAnimate = wasAnimating && abs(newValue - lastValue) > 0.5f 178 179 roundableState.setBottomRoundness(value = newValue, animated = shouldAnimate || animate) 180 return true 181 } 182 return false 183 } 184 185 /** 186 * Request the bottom roundness [value] for a specific [sourceType]. Animate the roundness if 187 * the view is shown. 188 * 189 * The bottom roundness of a [Roundable] can be defined by different [sourceType]. In case more 190 * origins require different roundness, for the same property, the maximum value will always be 191 * chosen. 192 * 193 * @param value value between 0f and 1f. 194 * @param sourceType the source from which the request for roundness comes. 195 * @return Whether the roundness was changed. 196 */ 197 @JvmDefault 198 fun requestBottomRoundness( 199 @FloatRange(from = 0.0, to = 1.0) value: Float, 200 sourceType: SourceType, 201 ): Boolean { 202 return requestBottomRoundness( 203 value = value, 204 sourceType = sourceType, 205 animate = roundableState.targetView.isShown 206 ) 207 } 208 209 /** 210 * Request the roundness [value] for a specific [sourceType]. 211 * 212 * The top/bottom roundness of a [Roundable] can be defined by different [sourceType]. In case 213 * more origins require different roundness, for the same property, the maximum value will 214 * always be chosen. 215 * 216 * @param top top value between 0f and 1f. 217 * @param bottom bottom value between 0f and 1f. 218 * @param sourceType the source from which the request for roundness comes. 219 * @param animate true if it should animate to that value. 220 * @return Whether the roundness was changed. 221 */ 222 @JvmDefault 223 fun requestRoundness( 224 @FloatRange(from = 0.0, to = 1.0) top: Float, 225 @FloatRange(from = 0.0, to = 1.0) bottom: Float, 226 sourceType: SourceType, 227 animate: Boolean, 228 ): Boolean { 229 val hasTopChanged = 230 requestTopRoundness(value = top, sourceType = sourceType, animate = animate) 231 val hasBottomChanged = 232 requestBottomRoundness(value = bottom, sourceType = sourceType, animate = animate) 233 return hasTopChanged || hasBottomChanged 234 } 235 236 /** 237 * Request the roundness [value] for a specific [sourceType]. Animate the roundness if the view 238 * is shown. 239 * 240 * The top/bottom roundness of a [Roundable] can be defined by different [sourceType]. In case 241 * more origins require different roundness, for the same property, the maximum value will 242 * always be chosen. 243 * 244 * @param top top value between 0f and 1f. 245 * @param bottom bottom value between 0f and 1f. 246 * @param sourceType the source from which the request for roundness comes. 247 * @return Whether the roundness was changed. 248 */ 249 @JvmDefault 250 fun requestRoundness( 251 @FloatRange(from = 0.0, to = 1.0) top: Float, 252 @FloatRange(from = 0.0, to = 1.0) bottom: Float, 253 sourceType: SourceType, 254 ): Boolean { 255 return requestRoundness( 256 top = top, 257 bottom = bottom, 258 sourceType = sourceType, 259 animate = roundableState.targetView.isShown, 260 ) 261 } 262 263 /** 264 * Request the roundness 0f for a [SourceType]. 265 * 266 * The top/bottom roundness of a [Roundable] can be defined by different [sourceType]. In case 267 * more origins require different roundness, for the same property, the maximum value will 268 * always be chosen. 269 * 270 * @param sourceType the source from which the request for roundness comes. 271 * @param animate true if it should animate to that value. 272 */ 273 @JvmDefault 274 fun requestRoundnessReset(sourceType: SourceType, animate: Boolean) { 275 requestRoundness(top = 0f, bottom = 0f, sourceType = sourceType, animate = animate) 276 } 277 278 /** 279 * Request the roundness 0f for a [SourceType]. Animate the roundness if the view is shown. 280 * 281 * The top/bottom roundness of a [Roundable] can be defined by different [sourceType]. In case 282 * more origins require different roundness, for the same property, the maximum value will 283 * always be chosen. 284 * 285 * @param sourceType the source from which the request for roundness comes. 286 */ 287 @JvmDefault 288 fun requestRoundnessReset(sourceType: SourceType) { 289 requestRoundnessReset(sourceType = sourceType, animate = roundableState.targetView.isShown) 290 } 291 292 /** Apply the roundness changes, usually means invalidate the [RoundableState.targetView]. */ 293 @JvmDefault 294 fun applyRoundnessAndInvalidate() { 295 roundableState.targetView.invalidate() 296 } 297 298 /** @return true if top or bottom roundness is not zero. */ 299 @JvmDefault 300 fun hasRoundedCorner(): Boolean { 301 return topRoundness != 0f || bottomRoundness != 0f 302 } 303 304 /** 305 * Update an Array of 8 values, 4 pairs of [X,Y] radii. As expected by param radii of 306 * [android.graphics.Path.addRoundRect]. 307 * 308 * This method reuses the previous [radii] for performance reasons. 309 */ 310 @JvmDefault 311 fun updateRadii( 312 topCornerRadius: Float, 313 bottomCornerRadius: Float, 314 radii: FloatArray, 315 ) { 316 if (radii.size != 8) error("Unexpected radiiBuffer size ${radii.size}") 317 318 if (radii[0] != topCornerRadius || radii[4] != bottomCornerRadius) { 319 (0..3).forEach { radii[it] = topCornerRadius } 320 (4..7).forEach { radii[it] = bottomCornerRadius } 321 } 322 } 323 } 324 325 /** 326 * State object for a `Roundable` class. 327 * 328 * @param targetView Will handle the [AnimatableProperty] 329 * @param roundable Target of the radius animation 330 * @param maxRadius Max corner radius in pixels 331 */ 332 class RoundableState 333 @JvmOverloads 334 constructor( 335 internal val targetView: View, 336 private val roundable: Roundable, 337 maxRadius: Float, 338 featureFlags: FeatureFlags? = null 339 ) { 340 internal var maxRadius = maxRadius 341 private set 342 343 internal val newHeadsUpAnim = ViewRefactorFlag(featureFlags, Flags.IMPROVED_HUN_ANIMATIONS) 344 345 /** Animatable for top roundness */ 346 private val topAnimatable = topAnimatable(roundable) 347 348 /** Animatable for bottom roundness */ 349 private val bottomAnimatable = bottomAnimatable(roundable) 350 351 /** Current top roundness. Use [setTopRoundness] to update this value */ 352 @set:FloatRange(from = 0.0, to = 1.0) 353 internal var topRoundness = 0f 354 private set 355 356 /** Current bottom roundness. Use [setBottomRoundness] to update this value */ 357 @set:FloatRange(from = 0.0, to = 1.0) 358 internal var bottomRoundness = 0f 359 private set 360 361 internal val topCornerRadius: Float 362 get() { 363 val height = roundable.clipHeight 364 val topRadius = topRoundness * maxRadius 365 val bottomRadius = bottomRoundness * maxRadius 366 367 if (height == 0) { 368 return 0f 369 } else if (topRadius + bottomRadius > height) { 370 // The sum of top and bottom corner radii should be at max the clipped height 371 val overShoot = topRadius + bottomRadius - height 372 return topRadius - (overShoot * topRoundness / (topRoundness + bottomRoundness)) 373 } 374 375 return topRadius 376 } 377 378 internal val bottomCornerRadius: Float 379 get() { 380 val height = roundable.clipHeight 381 val topRadius = topRoundness * maxRadius 382 val bottomRadius = bottomRoundness * maxRadius 383 384 if (height == 0) { 385 return 0f 386 } else if (topRadius + bottomRadius > height) { 387 // The sum of top and bottom corner radii should be at max the clipped height 388 val overShoot = topRadius + bottomRadius - height 389 return bottomRadius - 390 (overShoot * bottomRoundness / (topRoundness + bottomRoundness)) 391 } 392 393 return bottomRadius 394 } 395 396 /** Last requested top roundness associated by [SourceType] */ 397 internal val topRoundnessMap = mutableMapOf<SourceType, Float>() 398 399 /** Last requested bottom roundness associated by [SourceType] */ 400 internal val bottomRoundnessMap = mutableMapOf<SourceType, Float>() 401 402 /** Last cached radii */ 403 internal val radiiBuffer = FloatArray(8) 404 405 /** Is top roundness animation in progress? */ 406 internal fun isTopAnimating() = PropertyAnimator.isAnimating(targetView, topAnimatable) 407 408 /** Is bottom roundness animation in progress? */ 409 internal fun isBottomAnimating() = PropertyAnimator.isAnimating(targetView, bottomAnimatable) 410 411 /** Set the current top roundness */ 412 internal fun setTopRoundness( 413 value: Float, 414 animated: Boolean, 415 ) { 416 PropertyAnimator.setProperty(targetView, topAnimatable, value, DURATION, animated) 417 } 418 419 /** Set the current bottom roundness */ 420 internal fun setBottomRoundness( 421 value: Float, 422 animated: Boolean, 423 ) { 424 PropertyAnimator.setProperty(targetView, bottomAnimatable, value, DURATION, animated) 425 } 426 427 fun setMaxRadius(radius: Float) { 428 if (maxRadius != radius) { 429 maxRadius = radius 430 roundable.applyRoundnessAndInvalidate() 431 } 432 } 433 434 fun debugString() = buildString { 435 append("Roundable { ") 436 append("top: { value: $topRoundness, requests: $topRoundnessMap}") 437 append(", ") 438 append("bottom: { value: $bottomRoundness, requests: $bottomRoundnessMap}") 439 append("}") 440 } 441 442 companion object { 443 private val DURATION: AnimationProperties = 444 AnimationProperties() 445 .setDuration(StackStateAnimator.ANIMATION_DURATION_CORNER_RADIUS.toLong()) 446 447 private fun topAnimatable(roundable: Roundable): AnimatableProperty = 448 AnimatableProperty.from( 449 object : FloatProperty<View>("topRoundness") { 450 override fun get(view: View): Float = roundable.topRoundness 451 452 override fun setValue(view: View, value: Float) { 453 roundable.roundableState.topRoundness = value 454 roundable.applyRoundnessAndInvalidate() 455 } 456 }, 457 R.id.top_roundess_animator_tag, 458 R.id.top_roundess_animator_end_tag, 459 R.id.top_roundess_animator_start_tag, 460 ) 461 462 private fun bottomAnimatable(roundable: Roundable): AnimatableProperty = 463 AnimatableProperty.from( 464 object : FloatProperty<View>("bottomRoundness") { 465 override fun get(view: View): Float = roundable.bottomRoundness 466 467 override fun setValue(view: View, value: Float) { 468 roundable.roundableState.bottomRoundness = value 469 roundable.applyRoundnessAndInvalidate() 470 } 471 }, 472 R.id.bottom_roundess_animator_tag, 473 R.id.bottom_roundess_animator_end_tag, 474 R.id.bottom_roundess_animator_start_tag, 475 ) 476 } 477 } 478 479 /** 480 * Interface used to define the owner of a roundness. Usually the [SourceType] is defined as a 481 * private property of a class. 482 */ 483 interface SourceType { 484 companion object { 485 /** 486 * This is the most convenient way to define a new [SourceType]. 487 * 488 * For example: 489 * ```kotlin 490 * private val SECTION = SourceType.from("Section") 491 * ``` 492 */ 493 @JvmStatic 494 fun from(name: String) = 495 object : SourceType { 496 override fun toString() = name 497 } 498 } 499 } 500