1 /*
2  * Copyright (C) 2014 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 androidx.appcompat.graphics.drawable;
18 
19 import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX;
20 
21 import android.content.Context;
22 import android.content.res.TypedArray;
23 import android.graphics.Canvas;
24 import android.graphics.ColorFilter;
25 import android.graphics.Paint;
26 import android.graphics.Path;
27 import android.graphics.PixelFormat;
28 import android.graphics.Rect;
29 import android.graphics.drawable.Drawable;
30 import android.view.View;
31 
32 import androidx.annotation.ColorInt;
33 import androidx.annotation.FloatRange;
34 import androidx.annotation.IntDef;
35 import androidx.annotation.RestrictTo;
36 import androidx.appcompat.R;
37 import androidx.core.graphics.drawable.DrawableCompat;
38 import androidx.core.view.ViewCompat;
39 
40 import org.jspecify.annotations.NonNull;
41 
42 import java.lang.annotation.Retention;
43 import java.lang.annotation.RetentionPolicy;
44 
45 /**
46  * A drawable that can draw a "Drawer hamburger" menu or an arrow and animate between them.
47  * <p>
48  * The progress between the two states is controlled via {@link #setProgress(float)}.
49  * </p>
50  */
51 public class DrawerArrowDrawable extends Drawable {
52 
53     /**
54      * Direction to make the arrow point towards the left.
55      *
56      * @see #setDirection(int)
57      * @see #getDirection()
58      */
59     public static final int ARROW_DIRECTION_LEFT = 0;
60 
61     /**
62      * Direction to make the arrow point towards the right.
63      *
64      * @see #setDirection(int)
65      * @see #getDirection()
66      */
67     public static final int ARROW_DIRECTION_RIGHT = 1;
68 
69     /**
70      * Direction to make the arrow point towards the start.
71      *
72      * <p>When used in a view with a {@link ViewCompat#LAYOUT_DIRECTION_RTL RTL} layout direction,
73      * this is the same as {@link #ARROW_DIRECTION_RIGHT}, otherwise it is the same as
74      * {@link #ARROW_DIRECTION_LEFT}.</p>
75      *
76      * @see #setDirection(int)
77      * @see #getDirection()
78      */
79     public static final int ARROW_DIRECTION_START = 2;
80 
81     /**
82      * Direction to make the arrow point to the end.
83      *
84      * <p>When used in a view with a {@link ViewCompat#LAYOUT_DIRECTION_RTL RTL} layout direction,
85      * this is the same as {@link #ARROW_DIRECTION_LEFT}, otherwise it is the same as
86      * {@link #ARROW_DIRECTION_RIGHT}.</p>
87      *
88      * @see #setDirection(int)
89      * @see #getDirection()
90      */
91     public static final int ARROW_DIRECTION_END = 3;
92 
93     @RestrictTo(LIBRARY_GROUP_PREFIX)
94     @IntDef({ARROW_DIRECTION_LEFT, ARROW_DIRECTION_RIGHT,
95             ARROW_DIRECTION_START, ARROW_DIRECTION_END})
96     @Retention(RetentionPolicy.SOURCE)
97     public @interface ArrowDirection {}
98 
99     private final Paint mPaint = new Paint();
100 
101     // The angle in degrees that the arrow head is inclined at.
102     private static final float ARROW_HEAD_ANGLE = (float) Math.toRadians(45);
103     // The length of top and bottom bars when they merge into an arrow
104     private float mArrowHeadLength;
105     // The length of middle bar
106     private float mBarLength;
107     // The length of the middle bar when arrow is shaped
108     private float mArrowShaftLength;
109     // The space between bars when they are parallel
110     private float mBarGap;
111     // Whether bars should spin or not during progress
112     private boolean mSpin;
113     // Use Path instead of canvas operations so that if color has transparency, overlapping sections
114     // wont look different
115     private final Path mPath = new Path();
116     // The reported intrinsic size of the drawable.
117     private final int mSize;
118     // Whether we should mirror animation when animation is reversed.
119     private boolean mVerticalMirror = false;
120     // The interpolated version of the original progress
121     private float mProgress;
122     // the amount that overlaps w/ bar size when rotation is max
123     private float mMaxCutForBarSize;
124     // The arrow direction
125     private int mDirection = ARROW_DIRECTION_START;
126 
127     /**
128      * @param context used to get the configuration for the drawable from
129      */
DrawerArrowDrawable(Context context)130     public DrawerArrowDrawable(Context context) {
131         mPaint.setStyle(Paint.Style.STROKE);
132         mPaint.setStrokeJoin(Paint.Join.MITER);
133         mPaint.setStrokeCap(Paint.Cap.BUTT);
134         mPaint.setAntiAlias(true);
135 
136         final TypedArray a = context.getTheme().obtainStyledAttributes(null,
137                 R.styleable.DrawerArrowToggle, R.attr.drawerArrowStyle,
138                 R.style.Base_Widget_AppCompat_DrawerArrowToggle);
139 
140         setColor(a.getColor(R.styleable.DrawerArrowToggle_color, 0));
141         setBarThickness(a.getDimension(R.styleable.DrawerArrowToggle_thickness, 0));
142         setSpinEnabled(a.getBoolean(R.styleable.DrawerArrowToggle_spinBars, true));
143         // round this because having this floating may cause bad measurements
144         setGapSize(Math.round(a.getDimension(R.styleable.DrawerArrowToggle_gapBetweenBars, 0)));
145 
146         mSize = a.getDimensionPixelSize(R.styleable.DrawerArrowToggle_drawableSize, 0);
147         // round this because having this floating may cause bad measurements
148         mBarLength = Math.round(a.getDimension(R.styleable.DrawerArrowToggle_barLength, 0));
149         // round this because having this floating may cause bad measurements
150         mArrowHeadLength = Math.round(a.getDimension(
151                 R.styleable.DrawerArrowToggle_arrowHeadLength, 0));
152         mArrowShaftLength = a.getDimension(R.styleable.DrawerArrowToggle_arrowShaftLength, 0);
153         a.recycle();
154     }
155 
156     /**
157      * Sets the length of the arrow head (from tip to edge, perpendicular to the shaft).
158      *
159      * @param length the length in pixels
160      */
setArrowHeadLength(float length)161     public void setArrowHeadLength(float length) {
162         if (mArrowHeadLength != length) {
163             mArrowHeadLength = length;
164             invalidateSelf();
165         }
166     }
167 
168     /**
169      * Returns the length of the arrow head (from tip to edge, perpendicular to the shaft),
170      * in pixels.
171      */
getArrowHeadLength()172     public float getArrowHeadLength() {
173         return mArrowHeadLength;
174     }
175 
176     /**
177      * Sets the arrow shaft length.
178      *
179      * @param length the length in pixels
180      */
setArrowShaftLength(float length)181     public void setArrowShaftLength(float length) {
182         if (mArrowShaftLength != length) {
183             mArrowShaftLength = length;
184             invalidateSelf();
185         }
186     }
187 
188     /**
189      * Returns the arrow shaft length in pixels.
190      */
getArrowShaftLength()191     public float getArrowShaftLength() {
192         return mArrowShaftLength;
193     }
194 
195     /**
196      * The length of the bars when they are parallel to each other.
197      */
getBarLength()198     public float getBarLength() {
199         return mBarLength;
200     }
201 
202     /**
203      * Sets the length of the bars when they are parallel to each other.
204      *
205      * @param length the length in pixels
206      */
setBarLength(float length)207     public void setBarLength(float length) {
208         if (mBarLength != length) {
209             mBarLength = length;
210             invalidateSelf();
211         }
212     }
213 
214     /**
215      * Sets the color of the drawable.
216      */
setColor(@olorInt int color)217     public void setColor(@ColorInt int color) {
218         if (color != mPaint.getColor()) {
219             mPaint.setColor(color);
220             invalidateSelf();
221         }
222     }
223 
224     /**
225      * Returns the color of the drawable.
226      */
227     @ColorInt
getColor()228     public int getColor() {
229         return mPaint.getColor();
230     }
231 
232     /**
233      * Sets the thickness (stroke size) for the bars.
234      *
235      * @param width stroke width in pixels
236      */
setBarThickness(float width)237     public void setBarThickness(float width) {
238         if (mPaint.getStrokeWidth() != width) {
239             mPaint.setStrokeWidth(width);
240             mMaxCutForBarSize = (float) (width / 2 * Math.cos(ARROW_HEAD_ANGLE));
241             invalidateSelf();
242         }
243     }
244 
245     /**
246      * Returns the thickness (stroke width) of the bars.
247      */
getBarThickness()248     public float getBarThickness() {
249         return mPaint.getStrokeWidth();
250     }
251 
252     /**
253      * Returns the max gap between the bars when they are parallel to each other.
254      *
255      * @see #getGapSize()
256      */
getGapSize()257     public float getGapSize() {
258         return mBarGap;
259     }
260 
261     /**
262      * Sets the max gap between the bars when they are parallel to each other.
263      *
264      * @param gap the gap in pixels
265      *
266      * @see #getGapSize()
267      */
setGapSize(float gap)268     public void setGapSize(float gap) {
269         if (gap != mBarGap) {
270             mBarGap = gap;
271             invalidateSelf();
272         }
273     }
274 
275     /**
276      * Set the arrow direction.
277      */
setDirection(@rrowDirection int direction)278     public void setDirection(@ArrowDirection int direction) {
279         if (direction != mDirection) {
280             mDirection = direction;
281             invalidateSelf();
282         }
283     }
284 
285     /**
286      * Returns whether the bars should rotate or not during the transition.
287      *
288      * @see #setSpinEnabled(boolean)
289      */
isSpinEnabled()290     public boolean isSpinEnabled() {
291         return mSpin;
292     }
293 
294     /**
295      * Returns whether the bars should rotate or not during the transition.
296      *
297      * @param enabled true if the bars should rotate.
298      *
299      * @see #isSpinEnabled()
300      */
setSpinEnabled(boolean enabled)301     public void setSpinEnabled(boolean enabled) {
302         if (mSpin != enabled) {
303             mSpin = enabled;
304             invalidateSelf();
305         }
306     }
307 
308     /**
309      * Returns the arrow direction.
310      */
311     @ArrowDirection
getDirection()312     public int getDirection() {
313         return mDirection;
314     }
315 
316     /**
317      * If set, canvas is flipped when progress reached to end and going back to start.
318      */
setVerticalMirror(boolean verticalMirror)319     public void setVerticalMirror(boolean verticalMirror) {
320         if (mVerticalMirror != verticalMirror) {
321             mVerticalMirror = verticalMirror;
322             invalidateSelf();
323         }
324     }
325 
326     @Override
draw(@onNull Canvas canvas)327     public void draw(@NonNull Canvas canvas) {
328         Rect bounds = getBounds();
329 
330         final boolean flipToPointRight;
331         switch (mDirection) {
332             case ARROW_DIRECTION_LEFT:
333                 flipToPointRight = false;
334                 break;
335             case ARROW_DIRECTION_RIGHT:
336                 flipToPointRight = true;
337                 break;
338             case ARROW_DIRECTION_END:
339                 flipToPointRight = DrawableCompat.getLayoutDirection(this)
340                         == View.LAYOUT_DIRECTION_LTR;
341                 break;
342             case ARROW_DIRECTION_START:
343             default:
344                 flipToPointRight = DrawableCompat.getLayoutDirection(this)
345                         == View.LAYOUT_DIRECTION_RTL;
346                 break;
347         }
348 
349         // Interpolated widths of arrow bars
350 
351         float arrowHeadBarLength = (float) Math.sqrt(mArrowHeadLength * mArrowHeadLength * 2);
352         arrowHeadBarLength = lerp(mBarLength, arrowHeadBarLength, mProgress);
353         final float arrowShaftLength = lerp(mBarLength, mArrowShaftLength, mProgress);
354         // Interpolated size of middle bar
355         final float arrowShaftCut = Math.round(lerp(0, mMaxCutForBarSize, mProgress));
356         // The rotation of the top and bottom bars (that make the arrow head)
357         final float rotation = lerp(0, ARROW_HEAD_ANGLE, mProgress);
358 
359         // The whole canvas rotates as the transition happens
360         final float canvasRotate = lerp(flipToPointRight ? 0 : -180,
361                 flipToPointRight ? 180 : 0, mProgress);
362 
363         final float arrowWidth = Math.round(arrowHeadBarLength * Math.cos(rotation));
364         final float arrowHeight = Math.round(arrowHeadBarLength * Math.sin(rotation));
365 
366         mPath.rewind();
367         final float topBottomBarOffset = lerp(mBarGap + mPaint.getStrokeWidth(), -mMaxCutForBarSize,
368                 mProgress);
369 
370         final float arrowEdge = -arrowShaftLength / 2;
371         // draw middle bar
372         mPath.moveTo(arrowEdge + arrowShaftCut, 0);
373         mPath.rLineTo(arrowShaftLength - arrowShaftCut * 2, 0);
374 
375         // bottom bar
376         mPath.moveTo(arrowEdge, topBottomBarOffset);
377         mPath.rLineTo(arrowWidth, arrowHeight);
378 
379         // top bar
380         mPath.moveTo(arrowEdge, -topBottomBarOffset);
381         mPath.rLineTo(arrowWidth, -arrowHeight);
382 
383         mPath.close();
384 
385         canvas.save();
386 
387         // Rotate the whole canvas if spinning, if not, rotate it 180 to get
388         // the arrow pointing the other way for RTL.
389         final float barThickness = mPaint.getStrokeWidth();
390         final int remainingSpace = (int) (bounds.height() - barThickness * 3 - mBarGap * 2);
391         float yOffset = (remainingSpace / 4) * 2; // making sure it is a multiple of 2.
392         yOffset += barThickness * 1.5f + mBarGap;
393 
394         canvas.translate(bounds.centerX(), yOffset);
395         if (mSpin) {
396             canvas.rotate(canvasRotate * ((mVerticalMirror ^ flipToPointRight) ? -1 : 1));
397         } else if (flipToPointRight) {
398             canvas.rotate(180);
399         }
400         canvas.drawPath(mPath, mPaint);
401 
402         canvas.restore();
403     }
404 
405     @Override
setAlpha(int alpha)406     public void setAlpha(int alpha) {
407         if (alpha != mPaint.getAlpha()) {
408             mPaint.setAlpha(alpha);
409             invalidateSelf();
410         }
411     }
412 
413     @Override
setColorFilter(ColorFilter colorFilter)414     public void setColorFilter(ColorFilter colorFilter) {
415         mPaint.setColorFilter(colorFilter);
416         invalidateSelf();
417     }
418 
419     @Override
getIntrinsicHeight()420     public int getIntrinsicHeight() {
421         return mSize;
422     }
423 
424     @Override
getIntrinsicWidth()425     public int getIntrinsicWidth() {
426         return mSize;
427     }
428 
429     @Override
getOpacity()430     public int getOpacity() {
431         return PixelFormat.TRANSLUCENT;
432     }
433 
434     /**
435      * Returns the current progress of the arrow.
436      */
437     @FloatRange(from = 0.0, to = 1.0)
getProgress()438     public float getProgress() {
439         return mProgress;
440     }
441 
442     /**
443      * Set the progress of the arrow.
444      *
445      * <p>A value of {@code 0.0} indicates that the arrow should be drawn in its starting
446      * position. A value of {@code 1.0} indicates that the arrow should be drawn in its ending
447      * position.</p>
448      */
setProgress(@loatRangefrom = 0.0, to = 1.0) float progress)449     public void setProgress(@FloatRange(from = 0.0, to = 1.0) float progress) {
450         if (mProgress != progress) {
451             mProgress = progress;
452             invalidateSelf();
453         }
454     }
455 
456     /**
457      * Returns the paint instance used for all drawing.
458      */
getPaint()459     public final Paint getPaint() {
460         return mPaint;
461     }
462 
463     /**
464      * Linear interpolate between a and b with parameter t.
465      */
lerp(float a, float b, float t)466     private static float lerp(float a, float b, float t) {
467         return a + (b - a) * t;
468     }
469 }