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