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 @Override draw(Canvas canvas)82 public void draw(Canvas canvas) { 83 canvas.drawPath(mPath, mPaint); 84 } 85 86 @Override getOutline(Outline outline)87 public void getOutline(Outline outline) { 88 outline.setPath(mPath); 89 } 90 91 @Override getOpacity()92 public int getOpacity() { 93 return PixelFormat.TRANSLUCENT; 94 } 95 96 @Override setAlpha(int i)97 public void setAlpha(int i) { 98 mPaint.setAlpha(i); 99 } 100 101 @Override setColorFilter(ColorFilter colorFilter)102 public void setColorFilter(ColorFilter colorFilter) { 103 mPaint.setColorFilter(colorFilter); 104 } 105 addDownPointingRoundedTriangleToPath(float width, float height, float radius, Path path)106 private static void addDownPointingRoundedTriangleToPath(float width, float height, 107 float radius, Path path) { 108 // Calculated for the arrow pointing down, will be flipped later if needed. 109 110 // Theta is half of the angle inside the triangle tip 111 float tanTheta = width / (2.0f * height); 112 float theta = (float) atan(tanTheta); 113 114 // Some trigonometry to find the center of the circle for the rounded tip 115 float roundedPointCenterY = (float) (height - (radius / sin(theta))); 116 117 // p is the distance along the triangle side to the intersection with the point circle 118 float p = radius / tanTheta; 119 float lineRoundPointIntersectFromCenter = (float) (p * sin(theta)); 120 float lineRoundPointIntersectFromTop = (float) (height - (p * cos(theta))); 121 122 float centerX = width / 2.0f; 123 float thetaDeg = (float) toDegrees(theta); 124 125 path.reset(); 126 path.moveTo(0, 0); 127 // Draw the top 128 path.lineTo(width, 0); 129 // Draw the right side up to the circle intersection 130 path.lineTo( 131 centerX + lineRoundPointIntersectFromCenter, 132 lineRoundPointIntersectFromTop); 133 // Draw the rounded point 134 path.arcTo( 135 centerX - radius, 136 roundedPointCenterY - radius, 137 centerX + radius, 138 roundedPointCenterY + radius, 139 thetaDeg, 140 180 - (2 * thetaDeg), 141 false); 142 // Draw the left edge to close 143 path.lineTo(0, 0); 144 path.close(); 145 } 146 clipPopupBodyFromPath(float popupRadius, float popupWidth, float popupHeight, float arrowOffsetX, float arrowOffsetY, Path path)147 private static void clipPopupBodyFromPath(float popupRadius, float popupWidth, 148 float popupHeight, float arrowOffsetX, float arrowOffsetY, Path path) { 149 // Make a path that is used to clip the triangle, this represents the body of the popup 150 Path clipPiece = new Path(); 151 clipPiece.addRoundRect( 152 0, 0, popupWidth, popupHeight, 153 popupRadius, popupRadius, Path.Direction.CW); 154 // clipping is performed as if the arrow is pointing down and positioned on the left, the 155 // resulting path will be flipped as needed later. 156 // The extra 0.5 in the vertical offset is to close the gap between this anti-aliased object 157 // and the anti-aliased body of the popup. 158 clipPiece.offset(-arrowOffsetX, -popupHeight + arrowOffsetY - 0.5f); 159 path.op(clipPiece, Path.Op.DIFFERENCE); 160 } 161 } 162