1 /* 2 * Copyright (C) 2021 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.popup; 18 19 import static java.lang.Math.atan; 20 import static java.lang.Math.cos; 21 import static java.lang.Math.sin; 22 import static java.lang.Math.toDegrees; 23 24 import android.graphics.Canvas; 25 import android.graphics.ColorFilter; 26 import android.graphics.Matrix; 27 import android.graphics.Outline; 28 import android.graphics.Paint; 29 import android.graphics.Path; 30 import android.graphics.PixelFormat; 31 import android.graphics.drawable.Drawable; 32 33 /** 34 * A drawable for a very specific purpose. Used for the caret arrow on a rounded rectangle popup 35 * bubble. 36 * Draws a triangle with one rounded tip, the opposite edge is clipped by the body of the popup 37 * so there is no overlap when drawing them together. 38 */ 39 public class RoundedArrowDrawable extends Drawable { 40 41 private final Path mPath; 42 private final Paint mPaint; 43 44 /** 45 * Default constructor. 46 * 47 * @param width of the arrow. 48 * @param height of the arrow. 49 * @param radius of the tip of the arrow. 50 * @param popupRadius of the rect to clip this by. 51 * @param popupWidth of the rect to clip this by. 52 * @param popupHeight of the rect to clip this by. 53 * @param arrowOffsetX from the edge of the popup to the arrow. 54 * @param arrowOffsetY how much the arrow will overlap the popup. 55 * @param isPointingUp or not. 56 * @param leftAligned or false for right aligned. 57 * @param color to draw the triangle. 58 */ RoundedArrowDrawable(float width, float height, float radius, float popupRadius, float popupWidth, float popupHeight, float arrowOffsetX, float arrowOffsetY, boolean isPointingUp, boolean leftAligned, int color)59 public RoundedArrowDrawable(float width, float height, float radius, float popupRadius, 60 float popupWidth, float popupHeight, 61 float arrowOffsetX, float arrowOffsetY, boolean isPointingUp, boolean leftAligned, 62 int color) { 63 mPath = new Path(); 64 mPaint = new Paint(); 65 mPaint.setColor(color); 66 mPaint.setStyle(Paint.Style.FILL); 67 mPaint.setAntiAlias(true); 68 69 // Make the drawable with the triangle pointing down and positioned on the left.. 70 addDownPointingRoundedTriangleToPath(width, height, radius, mPath); 71 clipPopupBodyFromPath(popupRadius, popupWidth, popupHeight, arrowOffsetX, arrowOffsetY, 72 mPath); 73 74 // ... then flip it horizontal or vertical based on where it will be used. 75 Matrix pathTransform = new Matrix(); 76 pathTransform.setScale( 77 leftAligned ? 1 : -1, isPointingUp ? -1 : 1, width * 0.5f, height * 0.5f); 78 mPath.transform(pathTransform); 79 } 80 81 /** 82 * Constructor for an arrow that points to the left or right. 83 * 84 * @param width of the arrow. 85 * @param height of the arrow. 86 * @param radius of the tip of the arrow. 87 * @param isPointingLeft or not. 88 * @param color to draw the triangle. 89 */ RoundedArrowDrawable(float width, float height, float radius, boolean isPointingLeft, int color)90 public RoundedArrowDrawable(float width, float height, float radius, boolean isPointingLeft, 91 int color) { 92 mPath = new Path(); 93 mPaint = new Paint(); 94 mPaint.setColor(color); 95 mPaint.setStyle(Paint.Style.FILL); 96 mPaint.setAntiAlias(true); 97 98 // Make the drawable with the triangle pointing down... 99 addDownPointingRoundedTriangleToPath(width, height, radius, mPath); 100 101 // ... then rotate it to the side it needs to point. 102 Matrix pathTransform = new Matrix(); 103 pathTransform.setRotate(isPointingLeft ? 90 : -90, width * 0.5f, height * 0.5f); 104 mPath.transform(pathTransform); 105 } 106 107 @Override draw(Canvas canvas)108 public void draw(Canvas canvas) { 109 canvas.drawPath(mPath, mPaint); 110 } 111 112 @Override getOutline(Outline outline)113 public void getOutline(Outline outline) { 114 outline.setPath(mPath); 115 } 116 117 @Override getOpacity()118 public int getOpacity() { 119 return PixelFormat.TRANSLUCENT; 120 } 121 122 @Override setAlpha(int i)123 public void setAlpha(int i) { 124 mPaint.setAlpha(i); 125 } 126 127 @Override setColorFilter(ColorFilter colorFilter)128 public void setColorFilter(ColorFilter colorFilter) { 129 mPaint.setColorFilter(colorFilter); 130 } 131 addDownPointingRoundedTriangleToPath(float width, float height, float radius, Path path)132 private static void addDownPointingRoundedTriangleToPath(float width, float height, 133 float radius, Path path) { 134 // Calculated for the arrow pointing down, will be flipped later if needed. 135 136 // Theta is half of the angle inside the triangle tip 137 float tanTheta = width / (2.0f * height); 138 float theta = (float) atan(tanTheta); 139 140 // Some trigonometry to find the center of the circle for the rounded tip 141 float roundedPointCenterY = (float) (height - (radius / sin(theta))); 142 143 // p is the distance along the triangle side to the intersection with the point circle 144 float p = radius / tanTheta; 145 float lineRoundPointIntersectFromCenter = (float) (p * sin(theta)); 146 float lineRoundPointIntersectFromTop = (float) (height - (p * cos(theta))); 147 148 float centerX = width / 2.0f; 149 float thetaDeg = (float) toDegrees(theta); 150 151 path.reset(); 152 path.moveTo(0, 0); 153 // Draw the top 154 path.lineTo(width, 0); 155 // Draw the right side up to the circle intersection 156 path.lineTo( 157 centerX + lineRoundPointIntersectFromCenter, 158 lineRoundPointIntersectFromTop); 159 // Draw the rounded point 160 path.arcTo( 161 centerX - radius, 162 roundedPointCenterY - radius, 163 centerX + radius, 164 roundedPointCenterY + radius, 165 thetaDeg, 166 180 - (2 * thetaDeg), 167 false); 168 // Draw the left edge to close 169 path.lineTo(0, 0); 170 path.close(); 171 } 172 clipPopupBodyFromPath(float popupRadius, float popupWidth, float popupHeight, float arrowOffsetX, float arrowOffsetY, Path path)173 private static void clipPopupBodyFromPath(float popupRadius, float popupWidth, 174 float popupHeight, float arrowOffsetX, float arrowOffsetY, Path path) { 175 // Make a path that is used to clip the triangle, this represents the body of the popup 176 Path clipPiece = new Path(); 177 clipPiece.addRoundRect( 178 0, 0, popupWidth, popupHeight, 179 popupRadius, popupRadius, Path.Direction.CW); 180 // clipping is performed as if the arrow is pointing down and positioned on the left, the 181 // resulting path will be flipped as needed later. 182 // The extra 0.5 in the vertical offset is to close the gap between this anti-aliased object 183 // and the anti-aliased body of the popup. 184 clipPiece.offset(-arrowOffsetX, -popupHeight + arrowOffsetY - 0.5f); 185 path.op(clipPiece, Path.Op.DIFFERENCE); 186 } 187 } 188