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