1 package com.android.systemui.statusbar
2 
3 import android.content.Context
4 import android.graphics.Canvas
5 import android.graphics.Color
6 import android.graphics.Matrix
7 import android.graphics.Paint
8 import android.graphics.PointF
9 import android.graphics.PorterDuff
10 import android.graphics.PorterDuffColorFilter
11 import android.graphics.PorterDuffXfermode
12 import android.graphics.RadialGradient
13 import android.graphics.Shader
14 import android.os.Trace
15 import android.util.AttributeSet
16 import android.util.MathUtils.lerp
17 import android.view.MotionEvent
18 import android.view.View
19 import android.view.animation.PathInterpolator
20 import com.android.app.animation.Interpolators
21 import com.android.keyguard.logging.ScrimLogger
22 import com.android.systemui.shade.TouchLogger
23 import com.android.systemui.statusbar.LightRevealEffect.Companion.getPercentPastThreshold
24 import com.android.systemui.util.getColorWithAlpha
25 import com.android.systemui.util.leak.RotationUtils
26 import com.android.systemui.util.leak.RotationUtils.Rotation
27 import java.util.function.Consumer
28 
29 /**
30  * Provides methods to modify the various properties of a [LightRevealScrim] to reveal between 0% to
31  * 100% of the view(s) underneath the scrim.
32  */
33 interface LightRevealEffect {
setRevealAmountOnScrimnull34     fun setRevealAmountOnScrim(amount: Float, scrim: LightRevealScrim)
35 
36     companion object {
37 
38         /**
39          * Returns the percent that the given value is past the threshold value. For example, 0.9 is
40          * 50% of the way past 0.8.
41          */
42         fun getPercentPastThreshold(value: Float, threshold: Float): Float {
43             return (value - threshold).coerceAtLeast(0f) * (1f / (1f - threshold))
44         }
45     }
46 }
47 
48 /**
49  * Light reveal effect that shows light entering the phone from the bottom of the screen. The light
50  * enters from the bottom-middle as a narrow oval, and moves upward, eventually widening to fill the
51  * screen.
52  */
53 object LiftReveal : LightRevealEffect {
54 
55     /** Widen the oval of light after 35%, so it will eventually fill the screen. */
56     private const val WIDEN_OVAL_THRESHOLD = 0.35f
57 
58     /** After 85%, fade out the black color at the end of the gradient. */
59     private const val FADE_END_COLOR_OUT_THRESHOLD = 0.85f
60 
61     /** The initial width of the light oval, in percent of scrim width. */
62     private const val OVAL_INITIAL_WIDTH_PERCENT = 0.5f
63 
64     /** The initial top value of the light oval, in percent of scrim height. */
65     private const val OVAL_INITIAL_TOP_PERCENT = 1.1f
66 
67     /** The initial bottom value of the light oval, in percent of scrim height. */
68     private const val OVAL_INITIAL_BOTTOM_PERCENT = 1.2f
69 
70     /** Interpolator to use for the reveal amount. */
71     private val INTERPOLATOR = Interpolators.FAST_OUT_SLOW_IN_REVERSE
72 
setRevealAmountOnScrimnull73     override fun setRevealAmountOnScrim(amount: Float, scrim: LightRevealScrim) {
74         val interpolatedAmount = INTERPOLATOR.getInterpolation(amount)
75         val ovalWidthIncreaseAmount =
76             getPercentPastThreshold(interpolatedAmount, WIDEN_OVAL_THRESHOLD)
77 
78         val initialWidthMultiplier = (1f - OVAL_INITIAL_WIDTH_PERCENT) / 2f
79 
80         with(scrim) {
81             revealGradientEndColorAlpha =
82                 1f - getPercentPastThreshold(amount, FADE_END_COLOR_OUT_THRESHOLD)
83             setRevealGradientBounds(
84                 scrim.width * initialWidthMultiplier + -scrim.width * ovalWidthIncreaseAmount,
85                 scrim.height * OVAL_INITIAL_TOP_PERCENT - scrim.height * interpolatedAmount,
86                 scrim.width * (1f - initialWidthMultiplier) + scrim.width * ovalWidthIncreaseAmount,
87                 scrim.height * OVAL_INITIAL_BOTTOM_PERCENT + scrim.height * interpolatedAmount
88             )
89         }
90     }
91 }
92 
93 data class LinearLightRevealEffect(private val isVertical: Boolean) : LightRevealEffect {
94 
95     // Interpolator that reveals >80% of the content at 0.5 progress, makes revealing faster
96     private val interpolator =
97         PathInterpolator(
98             /* controlX1= */ 0.4f,
99             /* controlY1= */ 0f,
100             /* controlX2= */ 0.2f,
101             /* controlY2= */ 1f
102         )
103 
setRevealAmountOnScrimnull104     override fun setRevealAmountOnScrim(amount: Float, scrim: LightRevealScrim) {
105         val interpolatedAmount = interpolator.getInterpolation(amount)
106 
107         scrim.interpolatedRevealAmount = interpolatedAmount
108 
109         scrim.startColorAlpha =
110             getPercentPastThreshold(
111                 1 - interpolatedAmount,
112                 threshold = 1 - START_COLOR_REVEAL_PERCENTAGE
113             )
114 
115         scrim.revealGradientEndColorAlpha =
116             1f -
117                 getPercentPastThreshold(
118                     interpolatedAmount,
119                     threshold = REVEAL_GRADIENT_END_COLOR_ALPHA_START_PERCENTAGE
120                 )
121 
122         // Start changing gradient bounds later to avoid harsh gradient in the beginning
123         val gradientBoundsAmount = lerp(GRADIENT_START_BOUNDS_PERCENTAGE, 1.0f, interpolatedAmount)
124 
125         if (isVertical) {
126             scrim.setRevealGradientBounds(
127                 left = scrim.viewWidth / 2 - (scrim.viewWidth / 2) * gradientBoundsAmount,
128                 top = 0f,
129                 right = scrim.viewWidth / 2 + (scrim.viewWidth / 2) * gradientBoundsAmount,
130                 bottom = scrim.viewHeight.toFloat()
131             )
132         } else {
133             scrim.setRevealGradientBounds(
134                 left = 0f,
135                 top = scrim.viewHeight / 2 - (scrim.viewHeight / 2) * gradientBoundsAmount,
136                 right = scrim.viewWidth.toFloat(),
137                 bottom = scrim.viewHeight / 2 + (scrim.viewHeight / 2) * gradientBoundsAmount
138             )
139         }
140     }
141 
142     private companion object {
143         // From which percentage we should start the gradient reveal width
144         // E.g. if 0 - starts with 0px width, 0.3f - starts with 30% width
145         private const val GRADIENT_START_BOUNDS_PERCENTAGE = 0.3f
146 
147         // When to start changing alpha color of the gradient scrim
148         // E.g. if 0.6f - starts fading the gradient away at 60% and becomes completely
149         // transparent at 100%
150         private const val REVEAL_GRADIENT_END_COLOR_ALPHA_START_PERCENTAGE = 0.6f
151 
152         // When to finish displaying start color fill that reveals the content
153         // E.g. if 0.3f - the content won't be visible at 0% and it will gradually
154         // reduce the alpha until 30% (at this point the color fill is invisible)
155         private const val START_COLOR_REVEAL_PERCENTAGE = 0.3f
156     }
157 }
158 
159 data class LinearSideLightRevealEffect(private val isVertical: Boolean) : LightRevealEffect {
160 
setRevealAmountOnScrimnull161     override fun setRevealAmountOnScrim(amount: Float, scrim: LightRevealScrim) {
162         scrim.interpolatedRevealAmount = amount
163         scrim.startColorAlpha =
164             getPercentPastThreshold(1 - amount, threshold = 1 - START_COLOR_REVEAL_PERCENTAGE)
165         scrim.revealGradientEndColorAlpha =
166             1f -
167                 getPercentPastThreshold(
168                     amount,
169                     threshold = REVEAL_GRADIENT_END_COLOR_ALPHA_START_PERCENTAGE
170                 )
171 
172         val gradientBoundsAmount = lerp(GRADIENT_START_BOUNDS_PERCENTAGE, 1f, amount)
173         if (isVertical) {
174             scrim.setRevealGradientBounds(
175                 left = -(scrim.viewWidth) * gradientBoundsAmount,
176                 top = -(scrim.viewHeight) * gradientBoundsAmount,
177                 right = (scrim.viewWidth) * gradientBoundsAmount,
178                 bottom = (scrim.viewHeight) + (scrim.viewHeight) * gradientBoundsAmount
179             )
180         } else {
181             scrim.setRevealGradientBounds(
182                 left = -(scrim.viewWidth) * gradientBoundsAmount,
183                 top = -(scrim.viewHeight) * gradientBoundsAmount,
184                 right = (scrim.viewWidth) + (scrim.viewWidth) * gradientBoundsAmount,
185                 bottom = (scrim.viewHeight) * gradientBoundsAmount
186             )
187         }
188     }
189 
190     private companion object {
191         // From which percentage we should start the gradient reveal width
192         // E.g. if 0 - starts with 0px width, 0.6f - starts with 60% width
193         private const val GRADIENT_START_BOUNDS_PERCENTAGE: Float = 0.95f
194 
195         // When to start changing alpha color of the gradient scrim
196         // E.g. if 0.6f - starts fading the gradient away at 60% and becomes completely
197         // transparent at 100%
198         private const val REVEAL_GRADIENT_END_COLOR_ALPHA_START_PERCENTAGE: Float = 0.95f
199 
200         // When to finish displaying start color fill that reveals the content
201         // E.g. if 0.6f - the content won't be visible at 0% and it will gradually
202         // reduce the alpha until 60% (at this point the color fill is invisible)
203         private const val START_COLOR_REVEAL_PERCENTAGE: Float = 1f
204     }
205 }
206 
207 data class CircleReveal(
208     /** X-value of the circle center of the reveal. */
209     val centerX: Int,
210     /** Y-value of the circle center of the reveal. */
211     val centerY: Int,
212     /** Radius of initial state of circle reveal */
213     val startRadius: Int,
214     /** Radius of end state of circle reveal */
215     val endRadius: Int
216 ) : LightRevealEffect {
setRevealAmountOnScrimnull217     override fun setRevealAmountOnScrim(amount: Float, scrim: LightRevealScrim) {
218         // reveal amount updates already have an interpolator, so we intentionally use the
219         // non-interpolated amount
220         val fadeAmount = getPercentPastThreshold(amount, 0.5f)
221         val radius = startRadius + ((endRadius - startRadius) * amount)
222         scrim.interpolatedRevealAmount = amount
223         scrim.revealGradientEndColorAlpha = 1f - fadeAmount
224         scrim.setRevealGradientBounds(
225             centerX - radius /* left */,
226             centerY - radius /* top */,
227             centerX + radius /* right */,
228             centerY + radius /* bottom */
229         )
230     }
231 }
232 
233 data class PowerButtonReveal(
234     /** Approximate Y-value of the center of the power button on the physical device. */
235     val powerButtonY: Float
236 ) : LightRevealEffect {
237 
238     /**
239      * How far off the side of the screen to start the power button reveal, in terms of percent of
240      * the screen width. This ensures that the initial part of the animation (where the reveal is
241      * just a sliver) starts just off screen.
242      */
243     private val OFF_SCREEN_START_AMOUNT = 0.05f
244 
245     private val INCREASE_MULTIPLIER = 1.25f
246 
setRevealAmountOnScrimnull247     override fun setRevealAmountOnScrim(amount: Float, scrim: LightRevealScrim) {
248         val interpolatedAmount = Interpolators.FAST_OUT_SLOW_IN_REVERSE.getInterpolation(amount)
249         val fadeAmount = getPercentPastThreshold(interpolatedAmount, 0.5f)
250 
251         with(scrim) {
252             revealGradientEndColorAlpha = 1f - fadeAmount
253             interpolatedRevealAmount = interpolatedAmount
254             @Rotation val rotation = RotationUtils.getRotation(scrim.getContext())
255             if (rotation == RotationUtils.ROTATION_NONE) {
256                 setRevealGradientBounds(
257                     width * (1f + OFF_SCREEN_START_AMOUNT) -
258                         width * INCREASE_MULTIPLIER * interpolatedAmount,
259                     powerButtonY - height * interpolatedAmount,
260                     width * (1f + OFF_SCREEN_START_AMOUNT) +
261                         width * INCREASE_MULTIPLIER * interpolatedAmount,
262                     powerButtonY + height * interpolatedAmount
263                 )
264             } else if (rotation == RotationUtils.ROTATION_LANDSCAPE) {
265                 setRevealGradientBounds(
266                     powerButtonY - width * interpolatedAmount,
267                     (-height * OFF_SCREEN_START_AMOUNT) -
268                         height * INCREASE_MULTIPLIER * interpolatedAmount,
269                     powerButtonY + width * interpolatedAmount,
270                     (-height * OFF_SCREEN_START_AMOUNT) +
271                         height * INCREASE_MULTIPLIER * interpolatedAmount
272                 )
273             } else {
274                 // RotationUtils.ROTATION_SEASCAPE
275                 setRevealGradientBounds(
276                     (width - powerButtonY) - width * interpolatedAmount,
277                     height * (1f + OFF_SCREEN_START_AMOUNT) -
278                         height * INCREASE_MULTIPLIER * interpolatedAmount,
279                     (width - powerButtonY) + width * interpolatedAmount,
280                     height * (1f + OFF_SCREEN_START_AMOUNT) +
281                         height * INCREASE_MULTIPLIER * interpolatedAmount
282                 )
283             }
284         }
285     }
286 }
287 
288 private const val TAG = "LightRevealScrim"
289 
290 /**
291  * Scrim view that partially reveals the content underneath it using a [RadialGradient] with a
292  * transparent center. The center position, size, and stops of the gradient can be manipulated to
293  * reveal views below the scrim as if they are being 'lit up'.
294  */
295 class LightRevealScrim
296 @JvmOverloads
297 constructor(
298     context: Context?,
299     attrs: AttributeSet?,
300     initialWidth: Int? = null,
301     initialHeight: Int? = null
302 ) : View(context, attrs) {
303 
304     private val logString = this::class.simpleName!! + "@" + hashCode()
305 
306     /** Listener that is called if the scrim's opaqueness changes */
307     var isScrimOpaqueChangedListener: Consumer<Boolean>? = null
308 
309     var scrimLogger: ScrimLogger? = null
310 
311     /**
312      * How much of the underlying views are revealed, in percent. 0 means they will be completely
313      * obscured and 1 means they'll be fully visible.
314      */
315     var revealAmount: Float = 1f
316         set(value) {
317             if (field != value) {
318                 field = value
319                 if (value <= 0.0f || value >= 1.0f) {
320                     scrimLogger?.d(TAG, "revealAmount", "$value on $logString")
321                 }
322                 revealEffect.setRevealAmountOnScrim(value, this)
323                 updateScrimOpaque()
324                 Trace.traceCounter(
325                     Trace.TRACE_TAG_APP,
326                     "light_reveal_amount $logString",
327                     (field * 100).toInt()
328                 )
329                 invalidate()
330             }
331         }
332 
333     /**
334      * The [LightRevealEffect] used to manipulate the radial gradient whenever [revealAmount]
335      * changes.
336      */
337     var revealEffect: LightRevealEffect = LiftReveal
338         set(value) {
339             if (field != value) {
340                 field = value
341 
342                 revealEffect.setRevealAmountOnScrim(revealAmount, this)
343                 scrimLogger?.d(TAG, "revealEffect", "$value on $logString")
344                 invalidate()
345             }
346         }
347 
348     var revealGradientCenter = PointF()
349     var revealGradientWidth: Float = 0f
350     var revealGradientHeight: Float = 0f
351 
352     /**
353      * Keeps the initial value until the view is measured. See [LightRevealScrim.onMeasure].
354      *
355      * Needed as the view dimensions are used before the onMeasure pass happens, and without preset
356      * width and height some flicker during fold/unfold happens.
357      */
358     internal var viewWidth: Int = initialWidth ?: 0
359         private set
360 
361     internal var viewHeight: Int = initialHeight ?: 0
362         private set
363 
364     /**
365      * Alpha of the fill that can be used in the beginning of the animation to hide the content.
366      * Normally the gradient bounds are animated from small size so the content is not visible, but
367      * if the start gradient bounds allow to see some content this could be used to make the reveal
368      * smoother. It can help to add fade in effect in the beginning of the animation. The color of
369      * the fill is determined by [revealGradientEndColor].
370      *
371      * 0 - no fill and content is visible, 1 - the content is covered with the start color
372      */
373     var startColorAlpha = 0f
374         set(value) {
375             if (field != value) {
376                 field = value
377                 invalidate()
378             }
379         }
380 
381     var revealGradientEndColor: Int = Color.BLACK
382         set(value) {
383             if (field != value) {
384                 field = value
385                 setPaintColorFilter()
386             }
387         }
388 
389     var revealGradientEndColorAlpha = 0f
390         set(value) {
391             if (field != value) {
392                 field = value
393                 setPaintColorFilter()
394             }
395         }
396 
397     /** Is the scrim currently fully opaque */
398     var isScrimOpaque = false
399         private set(value) {
400             if (field != value) {
401                 field = value
402                 isScrimOpaqueChangedListener?.accept(field)
403                 scrimLogger?.d(TAG, "isScrimOpaque", "$value on $logString")
404             }
405         }
406 
407     var interpolatedRevealAmount: Float = 1f
408 
409     val isScrimAlmostOccludes: Boolean
410         get() {
411             // if the interpolatedRevealAmount less than 0.1, over 90% of the screen is black.
412             return interpolatedRevealAmount < 0.1f
413         }
414 
updateScrimOpaquenull415     private fun updateScrimOpaque() {
416         isScrimOpaque = revealAmount == 0.0f && alpha == 1.0f && visibility == VISIBLE
417     }
418 
setAlphanull419     override fun setAlpha(alpha: Float) {
420         super.setAlpha(alpha)
421         scrimLogger?.d(TAG, "alpha", "$alpha on $logString")
422         updateScrimOpaque()
423     }
424 
setVisibilitynull425     override fun setVisibility(visibility: Int) {
426         super.setVisibility(visibility)
427         scrimLogger?.d(TAG, "visibility", "$visibility on $logString")
428         updateScrimOpaque()
429     }
430 
431     /**
432      * Paint used to draw a transparent-to-white radial gradient. This will be scaled and translated
433      * via local matrix in [onDraw] so we never need to construct a new shader.
434      */
435     private val gradientPaint =
<lambda>null436         Paint().apply {
437             shader =
438                 RadialGradient(
439                     0f,
440                     0f,
441                     1f,
442                     intArrayOf(Color.TRANSPARENT, Color.WHITE),
443                     floatArrayOf(0f, 1f),
444                     Shader.TileMode.CLAMP
445                 )
446 
447             // SRC_OVER ensures that we draw the semitransparent pixels over other views in the same
448             // window, rather than outright replacing them.
449             xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_OVER)
450         }
451 
452     /**
453      * Matrix applied to [gradientPaint]'s RadialGradient shader to move the gradient to
454      * [revealGradientCenter] and set its size to [revealGradientWidth]/[revealGradientHeight],
455      * without needing to construct a new shader each time those properties change.
456      */
457     private val shaderGradientMatrix = Matrix()
458 
459     init {
460         revealEffect.setRevealAmountOnScrim(revealAmount, this)
461         setPaintColorFilter()
462         invalidate()
463     }
464 
onMeasurenull465     override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
466         super.onMeasure(widthMeasureSpec, heightMeasureSpec)
467         viewWidth = measuredWidth
468         viewHeight = measuredHeight
469     }
470     /**
471      * Sets bounds for the transparent oval gradient that reveals the views below the scrim. This is
472      * simply a helper method that sets [revealGradientCenter], [revealGradientWidth], and
473      * [revealGradientHeight] for you.
474      *
475      * This method does not call [invalidate]
476      * - you should do so once you're done changing properties.
477      */
setRevealGradientBoundsnull478     fun setRevealGradientBounds(left: Float, top: Float, right: Float, bottom: Float) {
479         revealGradientWidth = right - left
480         revealGradientHeight = bottom - top
481 
482         revealGradientCenter.x = left + (revealGradientWidth / 2f)
483         revealGradientCenter.y = top + (revealGradientHeight / 2f)
484     }
485 
onDrawnull486     override fun onDraw(canvas: Canvas) {
487         if (revealGradientWidth <= 0 || revealGradientHeight <= 0 || revealAmount == 0f) {
488             if (revealAmount < 1f) {
489                 canvas.drawColor(revealGradientEndColor)
490             }
491             return
492         }
493 
494         if (startColorAlpha > 0f) {
495             canvas.drawColor(getColorWithAlpha(revealGradientEndColor, startColorAlpha))
496         }
497 
498         with(shaderGradientMatrix) {
499             setScale(revealGradientWidth, revealGradientHeight, 0f, 0f)
500             postTranslate(revealGradientCenter.x, revealGradientCenter.y)
501 
502             gradientPaint.shader.setLocalMatrix(this)
503         }
504 
505         // Draw the gradient over the screen, then multiply the end color by it.
506         canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), gradientPaint)
507     }
508 
dispatchTouchEventnull509     override fun dispatchTouchEvent(event: MotionEvent): Boolean {
510         return TouchLogger.logDispatchTouch(TAG, event, super.dispatchTouchEvent(event))
511     }
512 
setPaintColorFilternull513     private fun setPaintColorFilter() {
514         gradientPaint.colorFilter =
515             PorterDuffColorFilter(
516                 getColorWithAlpha(revealGradientEndColor, revealGradientEndColorAlpha),
517                 PorterDuff.Mode.MULTIPLY
518             )
519     }
520 }
521