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