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 isHorizontal or not.
88      * @param isLeftOrTop  or not.
89      * @param color        to draw the triangle.
90      */
RoundedArrowDrawable(float width, float height, float radius, boolean isHorizontal, boolean isLeftOrTop, int color)91     private RoundedArrowDrawable(float width, float height, float radius, boolean isHorizontal,
92             boolean isLeftOrTop, int color) {
93         mPath = new Path();
94         mPaint = new Paint();
95         mPaint.setColor(color);
96         mPaint.setStyle(Paint.Style.FILL);
97         mPaint.setAntiAlias(true);
98 
99         // Make the drawable with the triangle pointing down...
100         addDownPointingRoundedTriangleToPath(width, height, radius, mPath);
101 
102         if (isHorizontal || isLeftOrTop) {
103             // ... then rotate it to the side it needs to point.
104             Matrix pathTransform = new Matrix();
105             int rotationAngle;
106             if (isHorizontal) {
107                 rotationAngle = isLeftOrTop ? 90 : -90;
108             } else {
109                 // it could only be vertical arrow pointing up
110                 rotationAngle = 180;
111             }
112             pathTransform.setRotate(rotationAngle, width * 0.5f, height * 0.5f);
113             mPath.transform(pathTransform);
114         }
115     }
116 
117     /**
118      * factory method for an arrow that points to the left or right.
119      *
120      * @param width          of the arrow.
121      * @param height         of the arrow.
122      * @param radius         of the tip of the arrow.
123      * @param isPointingLeft or not.
124      * @param color          to draw the triangle.
125      */
createHorizontalRoundedArrow(float width, float height, float radius, boolean isPointingLeft, int color)126     public static RoundedArrowDrawable createHorizontalRoundedArrow(float width, float height,
127             float radius, boolean isPointingLeft, int color) {
128         return new RoundedArrowDrawable(width, height, radius, true, isPointingLeft, color);
129     }
130 
131     /**
132      * factory method for an arrow that points to the left or right.
133      *
134      * @param width        of the arrow.
135      * @param height       of the arrow.
136      * @param radius       of the tip of the arrow.
137      * @param isPointingUp or not.
138      * @param color        to draw the triangle.
139      */
createVerticalRoundedArrow(float width, float height, float radius, boolean isPointingUp, int color)140     public static RoundedArrowDrawable createVerticalRoundedArrow(float width, float height,
141             float radius, boolean isPointingUp, int color) {
142         return new RoundedArrowDrawable(width, height, radius, false, isPointingUp, color);
143     }
144 
145     @Override
draw(Canvas canvas)146     public void draw(Canvas canvas) {
147         canvas.drawPath(mPath, mPaint);
148     }
149 
150     @Override
getOutline(Outline outline)151     public void getOutline(Outline outline) {
152         outline.setPath(mPath);
153     }
154 
155     @Override
getOpacity()156     public int getOpacity() {
157         return PixelFormat.TRANSLUCENT;
158     }
159 
160     @Override
setAlpha(int i)161     public void setAlpha(int i) {
162         mPaint.setAlpha(i);
163     }
164 
165     @Override
setColorFilter(ColorFilter colorFilter)166     public void setColorFilter(ColorFilter colorFilter) {
167         mPaint.setColorFilter(colorFilter);
168     }
169 
170     /**
171      * Set shadow layer to internal {@link Paint#setShadowLayer(float, float, float, int) paint}
172      * object
173      */
setShadowLayer(float shadowBlur, float dx, float dy, int shadowColor)174     public void setShadowLayer(float shadowBlur, float dx, float dy, int shadowColor) {
175         mPaint.setShadowLayer(shadowBlur, dx, dy, shadowColor);
176     }
177 
178     /**
179      * Adds rounded triangle pointing down to the provided {@link Path path} argument
180      */
addDownPointingRoundedTriangleToPath(float width, float height, float radius, Path path)181     public static void addDownPointingRoundedTriangleToPath(float width, float height,
182             float radius, Path path) {
183         // Calculated for the arrow pointing down, will be flipped later if needed.
184 
185         // Theta is half of the angle inside the triangle tip
186         float tanTheta = width / (2.0f * height);
187         float theta = (float) atan(tanTheta);
188 
189         // Some trigonometry to find the center of the circle for the rounded tip
190         float roundedPointCenterY = (float) (height - (radius / sin(theta)));
191 
192         // p is the distance along the triangle side to the intersection with the point circle
193         float p = radius / tanTheta;
194         float lineRoundPointIntersectFromCenter = (float) (p * sin(theta));
195         float lineRoundPointIntersectFromTop = (float) (height - (p * cos(theta)));
196 
197         float centerX = width / 2.0f;
198         float thetaDeg = (float) toDegrees(theta);
199 
200         path.reset();
201         path.moveTo(0, 0);
202         // Draw the top
203         path.lineTo(width, 0);
204         // Draw the right side up to the circle intersection
205         path.lineTo(
206                 centerX + lineRoundPointIntersectFromCenter,
207                 lineRoundPointIntersectFromTop);
208         // Draw the rounded point
209         path.arcTo(
210                 centerX - radius,
211                 roundedPointCenterY - radius,
212                 centerX + radius,
213                 roundedPointCenterY + radius,
214                 thetaDeg,
215                 180 - (2 * thetaDeg),
216                 false);
217         // Draw the left edge to close
218         path.lineTo(0, 0);
219         path.close();
220     }
221 
clipPopupBodyFromPath(float popupRadius, float popupWidth, float popupHeight, float arrowOffsetX, float arrowOffsetY, Path path)222     private static void clipPopupBodyFromPath(float popupRadius, float popupWidth,
223             float popupHeight, float arrowOffsetX, float arrowOffsetY, Path path) {
224         // Make a path that is used to clip the triangle, this represents the body of the popup
225         Path clipPiece = new Path();
226         clipPiece.addRoundRect(
227                 0, 0, popupWidth, popupHeight,
228                 popupRadius, popupRadius, Path.Direction.CW);
229         // clipping is performed as if the arrow is pointing down and positioned on the left, the
230         // resulting path will be flipped as needed later.
231         // The extra 0.5 in the vertical offset is to close the gap between this anti-aliased object
232         // and the anti-aliased body of the popup.
233         clipPiece.offset(-arrowOffsetX, -popupHeight + arrowOffsetY - 0.5f);
234         path.op(clipPiece, Path.Op.DIFFERENCE);
235     }
236 }
237