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