1 /* 2 * Copyright (C) 2024 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.launcher3.taskbar.bubbles.flyout 18 19 import android.content.Context 20 import android.content.res.Configuration 21 import android.content.res.Resources 22 import android.graphics.Canvas 23 import android.graphics.Color 24 import android.graphics.Outline 25 import android.graphics.Paint 26 import android.graphics.Path 27 import android.graphics.PointF 28 import android.graphics.Rect 29 import android.graphics.RectF 30 import android.view.LayoutInflater 31 import android.view.View 32 import android.view.ViewOutlineProvider 33 import android.widget.ImageView 34 import android.widget.TextView 35 import androidx.constraintlayout.widget.ConstraintLayout 36 import androidx.core.animation.ArgbEvaluator 37 import com.android.launcher3.R 38 import com.android.launcher3.popup.RoundedArrowDrawable 39 import kotlin.math.min 40 41 /** The flyout view used to notify the user of a new bubble notification. */ 42 class BubbleBarFlyoutView( 43 context: Context, 44 private val positioner: BubbleBarFlyoutPositioner, 45 scheduler: FlyoutScheduler? = null, 46 ) : ConstraintLayout(context) { 47 48 companion object { 49 // the rate multiple for the background color animation relative to the morph animation. 50 const val BACKGROUND_COLOR_CHANGE_RATE = 5 51 // the minimum progress of the expansion animation before the content starts fading in. 52 private const val MIN_EXPANSION_PROGRESS_FOR_CONTENT_ALPHA = 0.75f 53 54 private const val TEXT_ROW_HEIGHT_SP = 20 55 private const val MAX_ROWS_COUNT = 3 56 57 /** Returns the maximum possible height of the flyout view. */ getMaximumViewHeightnull58 fun getMaximumViewHeight(context: Context): Int { 59 val verticalPaddings = getFlyoutPadding(context) * 2 60 val textSizeSp = TEXT_ROW_HEIGHT_SP * MAX_ROWS_COUNT 61 val textSizePx = textSizeSp * Resources.getSystem().displayMetrics.scaledDensity 62 val triangleHeight = 63 context.resources.getDimensionPixelSize(R.dimen.bubblebar_flyout_triangle_height) 64 return verticalPaddings + textSizePx.toInt() + triangleHeight 65 } 66 getFlyoutPaddingnull67 private fun getFlyoutPadding(context: Context) = 68 context.resources.getDimensionPixelSize(R.dimen.bubblebar_flyout_padding) 69 } 70 71 private val scheduler: FlyoutScheduler = scheduler ?: HandlerScheduler(this) 72 private val title: TextView by 73 lazy(LazyThreadSafetyMode.NONE) { findViewById(R.id.bubble_flyout_title) } 74 75 private val icon: ImageView by <lambda>null76 lazy(LazyThreadSafetyMode.NONE) { findViewById(R.id.bubble_flyout_icon) } 77 78 private val message: TextView by <lambda>null79 lazy(LazyThreadSafetyMode.NONE) { findViewById(R.id.bubble_flyout_text) } 80 <lambda>null81 private val flyoutPadding by lazy(LazyThreadSafetyMode.NONE) { getFlyoutPadding(context) } 82 83 private val triangleHeight by <lambda>null84 lazy(LazyThreadSafetyMode.NONE) { 85 context.resources.getDimensionPixelSize(R.dimen.bubblebar_flyout_triangle_height) 86 } 87 88 private val triangleOverlap by <lambda>null89 lazy(LazyThreadSafetyMode.NONE) { 90 context.resources.getDimensionPixelSize( 91 R.dimen.bubblebar_flyout_triangle_overlap_amount 92 ) 93 } 94 95 private val triangleWidth by <lambda>null96 lazy(LazyThreadSafetyMode.NONE) { 97 context.resources.getDimensionPixelSize(R.dimen.bubblebar_flyout_triangle_width) 98 } 99 100 private val triangleRadius by <lambda>null101 lazy(LazyThreadSafetyMode.NONE) { 102 context.resources.getDimensionPixelSize(R.dimen.bubblebar_flyout_triangle_radius) 103 } 104 105 private val minFlyoutWidth by <lambda>null106 lazy(LazyThreadSafetyMode.NONE) { 107 context.resources.getDimensionPixelSize(R.dimen.bubblebar_flyout_min_width) 108 } 109 110 private val maxFlyoutWidth by <lambda>null111 lazy(LazyThreadSafetyMode.NONE) { 112 context.resources.getDimensionPixelSize(R.dimen.bubblebar_flyout_max_width) 113 } 114 115 private val flyoutElevation by <lambda>null116 lazy(LazyThreadSafetyMode.NONE) { 117 context.resources.getDimensionPixelSize(R.dimen.bubblebar_flyout_elevation).toFloat() 118 } 119 120 /** The bounds of the background rect. */ 121 private val backgroundRect = RectF() 122 private val cornerRadius: Float 123 private val triangle: Path = Path() 124 private val triangleOutline = Outline() 125 private var backgroundColor = Color.BLACK 126 /** Represents the progress of the expansion animation. 0 when collapsed. 1 when expanded. */ 127 private var expansionProgress = 0f 128 /** Translation x-y values to move the flyout to its collapsed position. */ 129 private var translationToCollapsedPosition = PointF(0f, 0f) 130 /** The size of the flyout when it's collapsed. */ 131 private var collapsedSize = 0f 132 /** The corner radius of the flyout when it's collapsed. */ 133 private var collapsedCornerRadius = 0f 134 /** The color of the flyout when collapsed. */ 135 private var collapsedColor = 0 136 /** The elevation of the flyout when collapsed. */ 137 private var collapsedElevation = 0f 138 /** The minimum progress of the expansion animation before the triangle is made visible. */ 139 private var minExpansionProgressForTriangle = 0f 140 141 /** The corner radius of the background according to the progress of the animation. */ 142 private val currentCornerRadius 143 get() = collapsedCornerRadius + (cornerRadius - collapsedCornerRadius) * expansionProgress 144 145 /** Translation X of the background. */ 146 private val backgroundRectTx 147 get() = translationToCollapsedPosition.x * (1 - expansionProgress) 148 149 /** Translation Y of the background. */ 150 private val backgroundRectTy 151 get() = translationToCollapsedPosition.y * (1 - expansionProgress) 152 153 /** 154 * The paint used to draw the background, whose color changes as the flyout transitions to the 155 * tinted notification dot. 156 */ 157 private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG) 158 159 /** The bounds of the flyout relative to the parent view. */ 160 val bounds = Rect() 161 162 init { 163 LayoutInflater.from(context).inflate(R.layout.bubblebar_flyout, this, true) 164 id = R.id.bubble_bar_flyout_view 165 166 val ta = context.obtainStyledAttributes(intArrayOf(android.R.attr.dialogCornerRadius)) 167 cornerRadius = ta.getDimensionPixelSize(0, 0).toFloat() 168 ta.recycle() 169 170 setWillNotDraw(false) 171 clipChildren = true 172 clipToPadding = false 173 174 val padding = context.resources.getDimensionPixelSize(R.dimen.bubblebar_flyout_padding) 175 // add extra padding to the bottom of the view to include the triangle 176 setPadding(padding, padding, padding, padding + triangleHeight - triangleOverlap) 177 translationZ = flyoutElevation 178 179 RoundedArrowDrawable.addDownPointingRoundedTriangleToPath( 180 triangleWidth.toFloat(), 181 triangleHeight.toFloat(), 182 triangleRadius.toFloat(), 183 triangle, 184 ) 185 triangleOutline.setPath(triangle) 186 187 outlineProvider = 188 object : ViewOutlineProvider() { getOutlinenull189 override fun getOutline(view: View, outline: Outline) { 190 this@BubbleBarFlyoutView.getOutline(outline) 191 } 192 } 193 clipToOutline = true 194 195 applyConfigurationColors(resources.configuration) 196 } 197 onLayoutnull198 override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { 199 super.onLayout(changed, left, top, right, bottom) 200 bounds.left = left 201 bounds.top = top 202 bounds.right = right 203 bounds.bottom = bottom 204 } 205 206 /** Sets the data for the flyout and starts playing the expand animation. */ showFromCollapsednull207 fun showFromCollapsed(flyoutMessage: BubbleBarFlyoutMessage, expandAnimation: () -> Unit) { 208 icon.alpha = 0f 209 title.alpha = 0f 210 message.alpha = 0f 211 setData(flyoutMessage) 212 213 updateTranslationToCollapsedPosition() 214 collapsedSize = positioner.collapsedSize 215 collapsedCornerRadius = collapsedSize / 2 216 collapsedColor = positioner.collapsedColor 217 collapsedElevation = positioner.collapsedElevation 218 219 // calculate the expansion progress required before we start showing the triangle as part of 220 // the expansion animation 221 minExpansionProgressForTriangle = 222 positioner.distanceToRevealTriangle / translationToCollapsedPosition.y 223 224 backgroundPaint.color = collapsedColor 225 226 // post the request to start the expand animation to the looper so the view can measure 227 // itself 228 scheduler.runAfterLayout(expandAnimation) 229 } 230 231 /** Updates the content of the flyout and schedules [afterLayout] to run after a layout pass. */ updateDatanull232 fun updateData(flyoutMessage: BubbleBarFlyoutMessage, afterLayout: () -> Unit) { 233 setData(flyoutMessage) 234 scheduler.runAfterLayout(afterLayout) 235 } 236 setDatanull237 private fun setData(flyoutMessage: BubbleBarFlyoutMessage) { 238 if (flyoutMessage.icon != null) { 239 icon.visibility = VISIBLE 240 icon.setImageDrawable(flyoutMessage.icon) 241 } else { 242 icon.visibility = GONE 243 } 244 245 val minTextViewWidth: Int 246 val maxTextViewWidth: Int 247 if (icon.visibility == VISIBLE) { 248 minTextViewWidth = minFlyoutWidth - icon.width - flyoutPadding * 2 249 maxTextViewWidth = maxFlyoutWidth - icon.width - flyoutPadding * 2 250 } else { 251 // when there's no avatar, the width of the text view is constant, so we're setting the 252 // min and max to the same value 253 minTextViewWidth = minFlyoutWidth - flyoutPadding * 2 254 maxTextViewWidth = minTextViewWidth 255 } 256 257 if (flyoutMessage.title.isEmpty()) { 258 title.visibility = GONE 259 } else { 260 title.minWidth = minTextViewWidth 261 title.maxWidth = maxTextViewWidth 262 title.text = flyoutMessage.title 263 title.visibility = VISIBLE 264 } 265 266 message.minWidth = minTextViewWidth 267 message.maxWidth = maxTextViewWidth 268 message.text = flyoutMessage.message 269 } 270 271 /** 272 * This should be called to update [translationToCollapsedPosition] before we start expanding or 273 * collapsing to make sure that we're animating the flyout to and from the correct position. 274 */ updateTranslationToCollapsedPositionnull275 fun updateTranslationToCollapsedPosition() { 276 val txToCollapsedPosition = 277 if (positioner.isOnLeft) { 278 positioner.distanceToCollapsedPosition.x 279 } else { 280 -positioner.distanceToCollapsedPosition.x 281 } 282 val tyToCollapsedPosition = 283 positioner.distanceToCollapsedPosition.y + triangleHeight - triangleOverlap 284 translationToCollapsedPosition = PointF(txToCollapsedPosition, tyToCollapsedPosition) 285 } 286 287 /** Updates the flyout view with the progress of the animation. */ updateExpansionProgressnull288 fun updateExpansionProgress(fraction: Float) { 289 expansionProgress = fraction 290 291 updateTranslationForAnimation(message) 292 updateTranslationForAnimation(title) 293 updateTranslationForAnimation(icon) 294 295 // start fading in the content only after we're past the threshold 296 val alpha = 297 ((expansionProgress - MIN_EXPANSION_PROGRESS_FOR_CONTENT_ALPHA) / 298 (1f - MIN_EXPANSION_PROGRESS_FOR_CONTENT_ALPHA)) 299 .coerceIn(0f, 1f) 300 title.alpha = alpha 301 message.alpha = alpha 302 icon.alpha = alpha 303 304 translationZ = 305 collapsedElevation + (flyoutElevation - collapsedElevation) * expansionProgress 306 307 invalidate() 308 } 309 onDrawnull310 override fun onDraw(canvas: Canvas) { 311 // interpolate the width, height, corner radius and translation based on the progress of the 312 // animation. 313 // the background is drawn from the bottom left corner to the top right corner if we're 314 // positioned on the left, and from the bottom right corner to the top left if we're 315 // positioned on the right. 316 317 // the current width of the background rect according to the progress of the animation 318 val currentWidth = collapsedSize + (width - collapsedSize) * expansionProgress 319 val rectBottom = height - triangleHeight + triangleOverlap 320 val currentHeight = collapsedSize + (rectBottom - collapsedSize) * expansionProgress 321 322 backgroundRect.set( 323 if (positioner.isOnLeft) 0f else width.toFloat() - currentWidth, 324 height.toFloat() - triangleHeight + triangleOverlap - currentHeight, 325 if (positioner.isOnLeft) currentWidth else width.toFloat(), 326 height.toFloat() - triangleHeight + triangleOverlap, 327 ) 328 329 // transform the flyout color between the collapsed and expanded states. the color 330 // transformation completes at a faster rate (BACKGROUND_COLOR_CHANGE_RATE) than the 331 // expansion animation. this helps make the color change smooth. 332 backgroundPaint.color = 333 ArgbEvaluator.getInstance() 334 .evaluate( 335 min(expansionProgress * BACKGROUND_COLOR_CHANGE_RATE, 1f), 336 collapsedColor, 337 backgroundColor, 338 ) 339 340 canvas.save() 341 canvas.translate(backgroundRectTx, backgroundRectTy) 342 // draw the background starting from the bottom left if we're positioned left, or the bottom 343 // right if we're positioned right. 344 canvas.drawRoundRect( 345 backgroundRect, 346 currentCornerRadius, 347 currentCornerRadius, 348 backgroundPaint, 349 ) 350 if (expansionProgress >= minExpansionProgressForTriangle) { 351 drawTriangle(canvas) 352 } 353 canvas.restore() 354 invalidateOutline() 355 super.onDraw(canvas) 356 } 357 drawTrianglenull358 private fun drawTriangle(canvas: Canvas) { 359 canvas.save() 360 val triangleX = 361 if (positioner.isOnLeft) { 362 currentCornerRadius 363 } else { 364 width - currentCornerRadius - triangleWidth 365 } 366 // instead of scaling the triangle, increasingly reveal it from the background. this has the 367 // effect of the triangle scaling. 368 369 // the translation y of the triangle before we start revealing it. align its bottom with the 370 // bottom of the rect 371 val triangleYCollapsed = height - triangleHeight - (triangleHeight - triangleOverlap) 372 // the translation y of the triangle when it's fully revealed 373 val triangleYExpanded = height - triangleHeight 374 val interpolatedExpansion = 375 ((expansionProgress - minExpansionProgressForTriangle) / 376 (1 - minExpansionProgressForTriangle)) 377 .coerceIn(0f, 1f) 378 val triangleY = 379 triangleYCollapsed + (triangleYExpanded - triangleYCollapsed) * interpolatedExpansion 380 canvas.translate(triangleX, triangleY) 381 canvas.drawPath(triangle, backgroundPaint) 382 triangleOutline.setPath(triangle) 383 triangleOutline.offset(triangleX.toInt(), triangleY.toInt()) 384 canvas.restore() 385 } 386 getOutlinenull387 private fun getOutline(outline: Outline) { 388 val path = Path() 389 path.addRoundRect( 390 backgroundRect, 391 currentCornerRadius, 392 currentCornerRadius, 393 Path.Direction.CW, 394 ) 395 if (expansionProgress >= minExpansionProgressForTriangle) { 396 path.addPath(triangleOutline.mPath) 397 } 398 outline.setPath(path) 399 outline.offset(backgroundRectTx.toInt(), backgroundRectTy.toInt()) 400 } 401 updateTranslationForAnimationnull402 private fun updateTranslationForAnimation(view: View) { 403 val tx = 404 if (positioner.isOnLeft) { 405 translationToCollapsedPosition.x - view.left 406 } else { 407 width - view.left - translationToCollapsedPosition.x 408 } 409 val ty = height - view.top + translationToCollapsedPosition.y 410 view.translationX = tx * (1f - expansionProgress) 411 view.translationY = ty * (1f - expansionProgress) 412 } 413 applyConfigurationColorsnull414 private fun applyConfigurationColors(configuration: Configuration) { 415 val nightModeFlags = configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK 416 val isNightModeOn = nightModeFlags == Configuration.UI_MODE_NIGHT_YES 417 val defaultBackgroundColor = if (isNightModeOn) Color.BLACK else Color.WHITE 418 val defaultTextColor = if (isNightModeOn) Color.WHITE else Color.BLACK 419 420 backgroundColor = 421 context.getColor(com.android.internal.R.color.materialColorSurfaceContainer) 422 title.setTextColor(context.getColor(com.android.internal.R.color.materialColorOnSurface)) 423 message.setTextColor( 424 context.getColor(com.android.internal.R.color.materialColorOnSurfaceVariant) 425 ) 426 backgroundPaint.color = backgroundColor 427 } 428 } 429