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