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