1/*
2 * Copyright (C) 2015 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.wearable.view;
18
19import android.animation.ArgbEvaluator;
20import android.animation.ValueAnimator;
21import android.animation.ValueAnimator.AnimatorUpdateListener;
22import android.annotation.TargetApi;
23import android.content.Context;
24import android.content.res.ColorStateList;
25import android.content.res.TypedArray;
26import android.graphics.Canvas;
27import android.graphics.Color;
28import android.graphics.Paint;
29import android.graphics.Paint.Style;
30import android.graphics.RadialGradient;
31import android.graphics.Rect;
32import android.graphics.RectF;
33import android.graphics.Shader;
34import android.graphics.drawable.Drawable;
35import android.os.Build;
36import android.util.AttributeSet;
37import android.view.View;
38
39import java.util.Objects;
40import com.android.packageinstaller.R;
41
42import com.android.packageinstaller.R;
43
44/**
45 * An image view surrounded by a circle.
46 */
47@TargetApi(Build.VERSION_CODES.LOLLIPOP)
48public class CircledImageView extends View {
49
50    private static final ArgbEvaluator ARGB_EVALUATOR = new ArgbEvaluator();
51
52    private Drawable mDrawable;
53
54    private final RectF mOval;
55    private final Paint mPaint;
56
57    private ColorStateList mCircleColor;
58
59    private float mCircleRadius;
60    private float mCircleRadiusPercent;
61
62    private float mCircleRadiusPressed;
63    private float mCircleRadiusPressedPercent;
64
65    private float mRadiusInset;
66
67    private int mCircleBorderColor;
68
69    private float mCircleBorderWidth;
70    private float mProgress = 1f;
71    private final float mShadowWidth;
72
73    private float mShadowVisibility;
74    private boolean mCircleHidden = false;
75
76    private float mInitialCircleRadius;
77
78    private boolean mPressed = false;
79
80    private boolean mProgressIndeterminate;
81    private ProgressDrawable mIndeterminateDrawable;
82    private Rect mIndeterminateBounds = new Rect();
83    private long mColorChangeAnimationDurationMs = 0;
84
85    private float mImageCirclePercentage = 1f;
86    private float mImageHorizontalOffcenterPercentage = 0f;
87    private Integer mImageTint;
88
89    private final Drawable.Callback mDrawableCallback = new Drawable.Callback() {
90        @Override
91        public void invalidateDrawable(Drawable drawable) {
92            invalidate();
93        }
94
95        @Override
96        public void scheduleDrawable(Drawable drawable, Runnable runnable, long l) {
97            // Not needed.
98        }
99
100        @Override
101        public void unscheduleDrawable(Drawable drawable, Runnable runnable) {
102            // Not needed.
103        }
104    };
105
106    private int mCurrentColor;
107
108    private final AnimatorUpdateListener mAnimationListener = new AnimatorUpdateListener() {
109        @Override
110        public void onAnimationUpdate(ValueAnimator animation) {
111            int color = (int) animation.getAnimatedValue();
112            if (color != CircledImageView.this.mCurrentColor) {
113                CircledImageView.this.mCurrentColor = color;
114                CircledImageView.this.invalidate();
115            }
116        }
117    };
118
119    private ValueAnimator mColorAnimator;
120
121    public CircledImageView(Context context) {
122        this(context, null);
123    }
124
125    public CircledImageView(Context context, AttributeSet attrs) {
126        this(context, attrs, 0);
127    }
128
129    public CircledImageView(Context context, AttributeSet attrs, int defStyle) {
130        super(context, attrs, defStyle);
131
132        TypedArray a = getContext().obtainStyledAttributes(attrs, R.styleable.CircledImageView);
133        mDrawable = a.getDrawable(R.styleable.CircledImageView_android_src);
134
135        mCircleColor = a.getColorStateList(R.styleable.CircledImageView_circle_color);
136        if (mCircleColor == null) {
137            mCircleColor = ColorStateList.valueOf(android.R.color.darker_gray);
138        }
139
140        mCircleRadius = a.getDimension(
141                R.styleable.CircledImageView_circle_radius, 0);
142        mInitialCircleRadius = mCircleRadius;
143        mCircleRadiusPressed = a.getDimension(
144                R.styleable.CircledImageView_circle_radius_pressed, mCircleRadius);
145        mCircleBorderColor = a.getColor(
146                R.styleable.CircledImageView_circle_border_color, Color.BLACK);
147        mCircleBorderWidth = a.getDimension(R.styleable.CircledImageView_circle_border_width, 0);
148
149        if (mCircleBorderWidth > 0) {
150            mRadiusInset += mCircleBorderWidth;
151        }
152
153        float circlePadding = a.getDimension(R.styleable.CircledImageView_circle_padding, 0);
154        if (circlePadding > 0) {
155            mRadiusInset += circlePadding;
156        }
157        mShadowWidth = a.getDimension(R.styleable.CircledImageView_shadow_width, 0);
158
159        mImageCirclePercentage = a.getFloat(
160                R.styleable.CircledImageView_image_circle_percentage, 0f);
161
162        mImageHorizontalOffcenterPercentage = a.getFloat(
163                R.styleable.CircledImageView_image_horizontal_offcenter_percentage, 0f);
164
165        if (a.hasValue(R.styleable.CircledImageView_image_tint)) {
166            mImageTint = a.getColor(R.styleable.CircledImageView_image_tint, 0);
167        }
168
169        mCircleRadiusPercent = a.getFraction(R.styleable.CircledImageView_circle_radius_percent,
170                1, 1, 0f);
171
172        mCircleRadiusPressedPercent = a.getFraction(
173                R.styleable.CircledImageView_circle_radius_pressed_percent, 1, 1,
174                mCircleRadiusPercent);
175
176        a.recycle();
177
178        mOval = new RectF();
179        mPaint = new Paint();
180        mPaint.setAntiAlias(true);
181
182        mIndeterminateDrawable = new ProgressDrawable();
183        // {@link #mDrawableCallback} must be retained as a member, as Drawable callback
184        // is held by weak reference, we must retain it for it to continue to be called.
185        mIndeterminateDrawable.setCallback(mDrawableCallback);
186
187        setWillNotDraw(false);
188
189        setColorForCurrentState();
190    }
191
192    public void setCircleHidden(boolean circleHidden) {
193        if (circleHidden != mCircleHidden) {
194            mCircleHidden = circleHidden;
195            invalidate();
196        }
197    }
198
199
200    @Override
201    protected boolean onSetAlpha(int alpha) {
202        return true;
203    }
204
205    @Override
206    protected void onDraw(Canvas canvas) {
207        int paddingLeft = getPaddingLeft();
208        int paddingTop = getPaddingTop();
209
210
211        float circleRadius = mPressed ? getCircleRadiusPressed() : getCircleRadius();
212        if (mShadowWidth > 0 && mShadowVisibility > 0) {
213            // First let's find the center of the view.
214            mOval.set(paddingLeft, paddingTop, getWidth() - getPaddingRight(),
215                    getHeight() - getPaddingBottom());
216            // Having the center, lets make the shadow start beyond the circled and possibly the
217            // border.
218            final float radius = circleRadius + mCircleBorderWidth +
219                    mShadowWidth * mShadowVisibility;
220            mPaint.setColor(Color.BLACK);
221            mPaint.setAlpha(Math.round(mPaint.getAlpha() * getAlpha()));
222            mPaint.setStyle(Style.FILL);
223            // TODO: precalc and pre-allocate this
224            mPaint.setShader(new RadialGradient(mOval.centerX(), mOval.centerY(), radius,
225                    new int[]{Color.BLACK, Color.TRANSPARENT}, new float[]{0.6f, 1f},
226                    Shader.TileMode.MIRROR));
227            canvas.drawCircle(mOval.centerX(), mOval.centerY(), radius, mPaint);
228            mPaint.setShader(null);
229        }
230        if (mCircleBorderWidth > 0) {
231            // First let's find the center of the view.
232            mOval.set(paddingLeft, paddingTop, getWidth() - getPaddingRight(),
233                    getHeight() - getPaddingBottom());
234            // Having the center, lets make the border meet the circle.
235            mOval.set(mOval.centerX() - circleRadius, mOval.centerY() - circleRadius,
236                    mOval.centerX() + circleRadius, mOval.centerY() + circleRadius);
237            mPaint.setColor(mCircleBorderColor);
238            // {@link #Paint.setAlpha} is a helper method that just sets the alpha portion of the
239            // color. {@link #Paint.setPaint} will clear any previously set alpha value.
240            mPaint.setAlpha(Math.round(mPaint.getAlpha() * getAlpha()));
241            mPaint.setStyle(Style.STROKE);
242            mPaint.setStrokeWidth(mCircleBorderWidth);
243
244            if (mProgressIndeterminate) {
245                mOval.roundOut(mIndeterminateBounds);
246                mIndeterminateDrawable.setBounds(mIndeterminateBounds);
247                mIndeterminateDrawable.setRingColor(mCircleBorderColor);
248                mIndeterminateDrawable.setRingWidth(mCircleBorderWidth);
249                mIndeterminateDrawable.draw(canvas);
250            } else {
251                canvas.drawArc(mOval, -90, 360 * mProgress, false, mPaint);
252            }
253        }
254        if (!mCircleHidden) {
255            mOval.set(paddingLeft, paddingTop, getWidth() - getPaddingRight(),
256                    getHeight() - getPaddingBottom());
257            // {@link #Paint.setAlpha} is a helper method that just sets the alpha portion of the
258            // color. {@link #Paint.setPaint} will clear any previously set alpha value.
259            mPaint.setColor(mCurrentColor);
260            mPaint.setAlpha(Math.round(mPaint.getAlpha() * getAlpha()));
261
262            mPaint.setStyle(Style.FILL);
263            float centerX = mOval.centerX();
264            float centerY = mOval.centerY();
265
266            canvas.drawCircle(centerX, centerY, circleRadius, mPaint);
267        }
268
269        if (mDrawable != null) {
270            mDrawable.setAlpha(Math.round(getAlpha() * 255));
271
272            if (mImageTint != null) {
273                mDrawable.setTint(mImageTint);
274            }
275            mDrawable.draw(canvas);
276        }
277
278        super.onDraw(canvas);
279    }
280
281    private void setColorForCurrentState() {
282        int newColor = mCircleColor.getColorForState(getDrawableState(),
283                mCircleColor.getDefaultColor());
284        if (mColorChangeAnimationDurationMs > 0) {
285            if (mColorAnimator != null) {
286                mColorAnimator.cancel();
287            } else {
288                mColorAnimator = new ValueAnimator();
289            }
290            mColorAnimator.setIntValues(new int[] {
291                    mCurrentColor, newColor });
292            mColorAnimator.setEvaluator(ARGB_EVALUATOR);
293            mColorAnimator.setDuration(mColorChangeAnimationDurationMs);
294            mColorAnimator.addUpdateListener(this.mAnimationListener);
295            mColorAnimator.start();
296        } else {
297            if (newColor != mCurrentColor) {
298                mCurrentColor = newColor;
299                invalidate();
300            }
301        }
302    }
303
304    @Override
305    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
306
307        final float radius = getCircleRadius() + mCircleBorderWidth +
308                mShadowWidth * mShadowVisibility;
309        float desiredWidth = radius * 2;
310        float desiredHeight = radius * 2;
311
312        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
313        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
314        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
315        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
316
317        int width;
318        int height;
319
320        if (widthMode == MeasureSpec.EXACTLY) {
321            width = widthSize;
322        } else if (widthMode == MeasureSpec.AT_MOST) {
323            width = (int) Math.min(desiredWidth, widthSize);
324        } else {
325            width = (int) desiredWidth;
326        }
327
328        if (heightMode == MeasureSpec.EXACTLY) {
329            height = heightSize;
330        } else if (heightMode == MeasureSpec.AT_MOST) {
331            height = (int) Math.min(desiredHeight, heightSize);
332        } else {
333            height = (int) desiredHeight;
334        }
335
336        super.onMeasure(MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
337                MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY));
338    }
339
340    @Override
341    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
342        if (mDrawable != null) {
343            // Retrieve the sizes of the drawable and the view.
344            final int nativeDrawableWidth = mDrawable.getIntrinsicWidth();
345            final int nativeDrawableHeight = mDrawable.getIntrinsicHeight();
346            final int viewWidth = getMeasuredWidth();
347            final int viewHeight = getMeasuredHeight();
348            final float imageCirclePercentage = mImageCirclePercentage > 0
349                    ? mImageCirclePercentage : 1;
350
351            final float scaleFactor = Math.min(1f,
352                    Math.min(
353                            (float) nativeDrawableWidth != 0
354                                    ? imageCirclePercentage * viewWidth / nativeDrawableWidth : 1,
355                            (float) nativeDrawableHeight != 0
356                                    ? imageCirclePercentage
357                                        * viewHeight / nativeDrawableHeight : 1));
358
359            // Scale the drawable down to fit the view, if needed.
360            final int drawableWidth = Math.round(scaleFactor * nativeDrawableWidth);
361            final int drawableHeight = Math.round(scaleFactor * nativeDrawableHeight);
362
363            // Center the drawable within the view.
364            final int drawableLeft = (viewWidth - drawableWidth) / 2
365                    + Math.round(mImageHorizontalOffcenterPercentage * drawableWidth);
366            final int drawableTop = (viewHeight - drawableHeight) / 2;
367
368            mDrawable.setBounds(drawableLeft, drawableTop, drawableLeft + drawableWidth,
369                    drawableTop + drawableHeight);
370        }
371
372        super.onLayout(changed, left, top, right, bottom);
373    }
374
375    public void setImageDrawable(Drawable drawable) {
376        if (drawable != mDrawable) {
377            final Drawable existingDrawable = mDrawable;
378            mDrawable = drawable;
379
380            final boolean skipLayout = drawable != null
381                    && existingDrawable != null
382                    && existingDrawable.getIntrinsicHeight() == drawable.getIntrinsicHeight()
383                    && existingDrawable.getIntrinsicWidth() == drawable.getIntrinsicWidth();
384
385            if (skipLayout) {
386                mDrawable.setBounds(existingDrawable.getBounds());
387            } else {
388                requestLayout();
389            }
390
391            invalidate();
392        }
393    }
394
395    public void setImageResource(int resId) {
396        setImageDrawable(resId == 0 ? null : getContext().getDrawable(resId));
397    }
398
399    public void setImageCirclePercentage(float percentage) {
400        float clamped = Math.max(0, Math.min(1, percentage));
401        if (clamped != mImageCirclePercentage) {
402            mImageCirclePercentage = clamped;
403            invalidate();
404        }
405    }
406
407    public void setImageHorizontalOffcenterPercentage(float percentage) {
408        if (percentage != mImageHorizontalOffcenterPercentage) {
409            mImageHorizontalOffcenterPercentage = percentage;
410            invalidate();
411        }
412    }
413
414    public void setImageTint(int tint) {
415        if (tint != mImageTint) {
416            mImageTint = tint;
417            invalidate();
418        }
419    }
420
421    public float getCircleRadius() {
422        float radius = mCircleRadius;
423        if (mCircleRadius <= 0 && mCircleRadiusPercent > 0) {
424            radius = Math.max(getMeasuredHeight(), getMeasuredWidth()) * mCircleRadiusPercent;
425        }
426
427        return radius - mRadiusInset;
428    }
429
430    public float getCircleRadiusPercent() {
431        return mCircleRadiusPercent;
432    }
433
434    public float getCircleRadiusPressed() {
435        float radius = mCircleRadiusPressed;
436
437        if (mCircleRadiusPressed <= 0 && mCircleRadiusPressedPercent > 0) {
438            radius = Math.max(getMeasuredHeight(), getMeasuredWidth())
439                    * mCircleRadiusPressedPercent;
440        }
441
442        return radius - mRadiusInset;
443    }
444
445    public float getCircleRadiusPressedPercent() {
446        return mCircleRadiusPressedPercent;
447    }
448
449    public void setCircleRadius(float circleRadius) {
450        if (circleRadius != mCircleRadius) {
451            mCircleRadius = circleRadius;
452            invalidate();
453        }
454    }
455
456    /**
457     * Sets the radius of the circle to be a percentage of the largest dimension of the view.
458     * @param circleRadiusPercent A {@code float} from 0 to 1 representing the radius percentage.
459     */
460    public void setCircleRadiusPercent(float circleRadiusPercent) {
461        if (circleRadiusPercent != mCircleRadiusPercent) {
462            mCircleRadiusPercent = circleRadiusPercent;
463            invalidate();
464        }
465    }
466
467    public void setCircleRadiusPressed(float circleRadiusPressed) {
468        if (circleRadiusPressed != mCircleRadiusPressed) {
469            mCircleRadiusPressed = circleRadiusPressed;
470            invalidate();
471        }
472    }
473
474    /**
475     * Sets the radius of the circle to be a percentage of the largest dimension of the view when
476     * pressed.
477     * @param circleRadiusPressedPercent A {@code float} from 0 to 1 representing the radius
478     *                                   percentage.
479     */
480    public void setCircleRadiusPressedPercent(float circleRadiusPressedPercent) {
481        if (circleRadiusPressedPercent  != mCircleRadiusPressedPercent) {
482            mCircleRadiusPressedPercent = circleRadiusPressedPercent;
483            invalidate();
484        }
485    }
486
487    @Override
488    protected void drawableStateChanged() {
489        super.drawableStateChanged();
490        setColorForCurrentState();
491    }
492
493    public void setCircleColor(int circleColor) {
494        setCircleColorStateList(ColorStateList.valueOf(circleColor));
495    }
496
497    public void setCircleColorStateList(ColorStateList circleColor) {
498        if (!Objects.equals(circleColor, mCircleColor)) {
499            mCircleColor = circleColor;
500            setColorForCurrentState();
501            invalidate();
502        }
503    }
504
505    public ColorStateList getCircleColorStateList() {
506        return mCircleColor;
507    }
508
509    public int getDefaultCircleColor() {
510        return mCircleColor.getDefaultColor();
511    }
512
513    /**
514     * Show the circle border as an indeterminate progress spinner.
515     * The views circle border width and color must be set for this to have an effect.
516     *
517     * @param show true if the progress spinner is shown, false to hide it.
518     */
519    public void showIndeterminateProgress(boolean show) {
520        mProgressIndeterminate = show;
521        if (show) {
522            mIndeterminateDrawable.startAnimation();
523        } else {
524            mIndeterminateDrawable.stopAnimation();
525        }
526    }
527
528    @Override
529    protected void onVisibilityChanged(View changedView, int visibility) {
530        super.onVisibilityChanged(changedView, visibility);
531        if (visibility != View.VISIBLE) {
532            showIndeterminateProgress(false);
533        } else if (mProgressIndeterminate) {
534            showIndeterminateProgress(true);
535        }
536    }
537
538    public void setProgress(float progress) {
539        if (progress != mProgress) {
540            mProgress = progress;
541            invalidate();
542        }
543    }
544
545    /**
546     * Set how much of the shadow should be shown.
547     * @param shadowVisibility Value between 0 and 1.
548     */
549    public void setShadowVisibility(float shadowVisibility) {
550        if (shadowVisibility != mShadowVisibility) {
551            mShadowVisibility = shadowVisibility;
552            invalidate();
553        }
554    }
555
556    public float getInitialCircleRadius() {
557        return mInitialCircleRadius;
558    }
559
560    public void setCircleBorderColor(int circleBorderColor) {
561        mCircleBorderColor = circleBorderColor;
562    }
563
564    /**
565     * Set the border around the circle.
566     * @param circleBorderWidth Width of the border around the circle.
567     */
568    public void setCircleBorderWidth(float circleBorderWidth) {
569        if (circleBorderWidth != mCircleBorderWidth) {
570            mCircleBorderWidth = circleBorderWidth;
571            invalidate();
572        }
573    }
574
575    @Override
576    public void setPressed(boolean pressed) {
577        super.setPressed(pressed);
578        if (pressed != mPressed) {
579            mPressed = pressed;
580            invalidate();
581        }
582    }
583
584    public Drawable getImageDrawable() {
585        return mDrawable;
586    }
587
588    /**
589     * @return the milliseconds duration of the transition animation when the color changes.
590     */
591    public long getColorChangeAnimationDuration() {
592        return mColorChangeAnimationDurationMs;
593    }
594
595    /**
596     * @param mColorChangeAnimationDurationMs the milliseconds duration of the color change
597     *            animation. The color change animation will run if the color changes with {@link #setCircleColor}
598     *            or as a result of the active state changing.
599     */
600    public void setColorChangeAnimationDuration(long mColorChangeAnimationDurationMs) {
601        this.mColorChangeAnimationDurationMs = mColorChangeAnimationDurationMs;
602    }
603}
604