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