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