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