1 /*
<lambda>null2  * Copyright (C) 2023 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 package com.android.wm.shell.common.bubbles
17 
18 import android.annotation.ColorInt
19 import android.graphics.Canvas
20 import android.graphics.ColorFilter
21 import android.graphics.Matrix
22 import android.graphics.Outline
23 import android.graphics.Paint
24 import android.graphics.Path
25 import android.graphics.Rect
26 import android.graphics.RectF
27 import android.graphics.drawable.Drawable
28 import kotlin.math.atan
29 import kotlin.math.cos
30 import kotlin.math.sin
31 import kotlin.properties.Delegates
32 
33 /** A drawable for the [BubblePopupView] that draws a popup background with a directional arrow */
34 class BubblePopupDrawable(val config: Config) : Drawable() {
35     /** The direction of the arrow in the popup drawable */
36     enum class ArrowDirection {
37         UP,
38         DOWN
39     }
40 
41     /** The arrow position on the side of the popup bubble */
42     sealed class ArrowPosition {
43         object Start : ArrowPosition()
44         object Center : ArrowPosition()
45         object End : ArrowPosition()
46         class Custom(val value: Float) : ArrowPosition()
47     }
48 
49     /** The configuration for drawable features */
50     data class Config(
51         @ColorInt val color: Int,
52         val cornerRadius: Float,
53         val contentPadding: Int,
54         val arrowWidth: Float,
55         val arrowHeight: Float,
56         val arrowRadius: Float
57     )
58 
59     /**
60      * The direction of the arrow in the popup drawable. It affects the content padding and requires
61      * it to be updated in the view.
62      */
63     var arrowDirection: ArrowDirection by
64         Delegates.observable(ArrowDirection.UP) { _, _, _ -> requestPathUpdate() }
65 
66     /**
67      * Arrow position along the X axis and its direction. The position is adjusted to the content
68      * corner radius when applied so it doesn't go into rounded corner area
69      */
70     var arrowPosition: ArrowPosition by
71         Delegates.observable(ArrowPosition.Center) { _, _, _ -> requestPathUpdate() }
72 
73     private val path = Path()
74     private val paint = Paint()
75     private var shouldUpdatePath = true
76 
77     init {
78         paint.color = config.color
79         paint.style = Paint.Style.FILL
80         paint.isAntiAlias = true
81     }
82 
83     override fun draw(canvas: Canvas) {
84         updatePathIfNeeded()
85         canvas.drawPath(path, paint)
86     }
87 
88     override fun onBoundsChange(bounds: Rect) {
89         requestPathUpdate()
90     }
91 
92     /** Should be applied to the view padding if arrow direction changes */
93     override fun getPadding(padding: Rect): Boolean {
94         padding.set(
95             config.contentPadding,
96             config.contentPadding,
97             config.contentPadding,
98             config.contentPadding
99         )
100         when (arrowDirection) {
101             ArrowDirection.UP -> padding.top += config.arrowHeight.toInt()
102             ArrowDirection.DOWN -> padding.bottom += config.arrowHeight.toInt()
103         }
104         return true
105     }
106 
107     override fun getOutline(outline: Outline) {
108         updatePathIfNeeded()
109         outline.setPath(path)
110     }
111 
112     override fun getOpacity(): Int {
113         return paint.alpha
114     }
115 
116     override fun setAlpha(alpha: Int) {
117         paint.alpha = alpha
118     }
119 
120     override fun setColorFilter(colorFilter: ColorFilter?) {
121         paint.colorFilter = colorFilter
122     }
123 
124     /** Schedules path update for the next redraw */
125     private fun requestPathUpdate() {
126         shouldUpdatePath = true
127     }
128 
129     /** Updates the path if required, when bounds or arrow direction/position changes */
130     private fun updatePathIfNeeded() {
131         if (shouldUpdatePath) {
132             updatePath()
133             shouldUpdatePath = false
134         }
135     }
136 
137     /** Updates the path value using the current bounds, config, arrow direction and position */
138     private fun updatePath() {
139         if (bounds.isEmpty) return
140         // Reset the path state
141         path.reset()
142         // The content rect where the filled rounded rect will be drawn
143         val contentRect = RectF(bounds)
144         when (arrowDirection) {
145             ArrowDirection.UP -> {
146                 // Add rounded arrow pointing up to the path
147                 addRoundedArrowPositioned(path, arrowPosition)
148                 // Inset content rect by the arrow size from the top
149                 contentRect.top += config.arrowHeight
150             }
151             ArrowDirection.DOWN -> {
152                 val matrix = Matrix()
153                 // Flip the path with the matrix to draw arrow pointing down
154                 matrix.setScale(1f, -1f, bounds.width() / 2f, bounds.height() / 2f)
155                 path.transform(matrix)
156                 // Add rounded arrow with the flipped matrix applied, will point down
157                 addRoundedArrowPositioned(path, arrowPosition)
158                 // Restore the path matrix to the original state with inverted matrix
159                 matrix.invert(matrix)
160                 path.transform(matrix)
161                 // Inset content rect by the arrow size from the bottom
162                 contentRect.bottom -= config.arrowHeight
163             }
164         }
165         // Add the content area rounded rect
166         path.addRoundRect(contentRect, config.cornerRadius, config.cornerRadius, Path.Direction.CW)
167     }
168 
169     /** Add a rounded arrow pointing up in the horizontal position on the canvas */
170     private fun addRoundedArrowPositioned(path: Path, position: ArrowPosition) {
171         val matrix = Matrix()
172         var translationX = positionValue(position) - config.arrowWidth / 2
173         // Offset to position between rounded corners of the content view
174         translationX = translationX.coerceIn(config.cornerRadius,
175                 bounds.width() - config.cornerRadius - config.arrowWidth)
176         // Translate to add the arrow in the center horizontally
177         matrix.setTranslate(-translationX, 0f)
178         path.transform(matrix)
179         // Add rounded arrow
180         addRoundedArrow(path)
181         // Restore the path matrix to the original state with inverted matrix
182         matrix.invert(matrix)
183         path.transform(matrix)
184     }
185 
186     /** Adds a rounded arrow pointing up to the path, can be flipped if needed */
187     private fun addRoundedArrow(path: Path) {
188         // Theta is half of the angle inside the triangle tip
189         val thetaTan = config.arrowWidth / (config.arrowHeight * 2f)
190         val theta = atan(thetaTan)
191         val thetaDeg = Math.toDegrees(theta.toDouble()).toFloat()
192         // The center Y value of the circle for the triangle tip
193         val tipCircleCenterY = config.arrowRadius / sin(theta)
194         // The length from triangle tip to intersection point with the circle
195         val tipIntersectionSideLength = config.arrowRadius / thetaTan
196         // The offset from the top to the point of intersection
197         val intersectionTopOffset = tipIntersectionSideLength * cos(theta)
198         // The offset from the center to the point of intersection
199         val intersectionCenterOffset = tipIntersectionSideLength * sin(theta)
200         // The center X of the triangle
201         val arrowCenterX = config.arrowWidth / 2f
202 
203         // Set initial position in bottom left of the arrow
204         path.moveTo(0f, config.arrowHeight)
205         // Add the left side of the triangle
206         path.lineTo(arrowCenterX - intersectionCenterOffset, intersectionTopOffset)
207         // Add the arc from the left to the right side of the triangle
208         path.arcTo(
209             /* left = */ arrowCenterX - config.arrowRadius,
210             /* top = */ tipCircleCenterY - config.arrowRadius,
211             /* right = */ arrowCenterX + config.arrowRadius,
212             /* bottom = */ tipCircleCenterY + config.arrowRadius,
213             /* startAngle = */ 180 + thetaDeg,
214             /* sweepAngle = */ 180 - (2 * thetaDeg),
215             /* forceMoveTo = */ false
216         )
217         // Add the right side of the triangle
218         path.lineTo(config.arrowWidth, config.arrowHeight)
219         // Close the path
220         path.close()
221     }
222 
223     /** The value of the arrow position provided the position and current bounds */
224     private fun positionValue(position: ArrowPosition): Float {
225         return when (position) {
226             is ArrowPosition.Start -> 0f
227             is ArrowPosition.Center -> bounds.width().toFloat() / 2f
228             is ArrowPosition.End -> bounds.width().toFloat()
229             is ArrowPosition.Custom -> position.value
230         }
231     }
232 }
233