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