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