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