<lambda>null1 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? */
isTopAnimatingnull406 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 */
setBottomRoundnessnull420 internal fun setBottomRoundness(
421 value: Float,
422 animated: Boolean,
423 ) {
424 PropertyAnimator.setProperty(targetView, bottomAnimatable, value, DURATION, animated)
425 }
426
setMaxRadiusnull427 fun setMaxRadius(radius: Float) {
428 if (maxRadius != radius) {
429 maxRadius = radius
430 roundable.applyRoundnessAndInvalidate()
431 }
432 }
433
<lambda>null434 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
topAnimatablenull447 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
bottomAnimatablenull462 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
fromnull494 fun from(name: String) =
495 object : SourceType {
496 override fun toString() = name
497 }
498 }
499 }
500