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