xref: /aosp_15_r20/frameworks/base/packages/SystemUI/src/com/android/systemui/statusbar/notification/Roundable.kt (revision d57664e9bc4670b3ecf6748a746a57c557b6bc9e)

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