1 /*
2  * Copyright (C) 2017 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.widget;
18 
19 import android.animation.ArgbEvaluator;
20 import android.animation.ValueAnimator;
21 import android.animation.ValueAnimator.AnimatorUpdateListener;
22 import android.content.Context;
23 import android.content.res.ColorStateList;
24 import android.content.res.TypedArray;
25 import android.graphics.Canvas;
26 import android.graphics.Color;
27 import android.graphics.Paint;
28 import android.graphics.Paint.Style;
29 import android.graphics.RadialGradient;
30 import android.graphics.Rect;
31 import android.graphics.RectF;
32 import android.graphics.Shader;
33 import android.graphics.drawable.Drawable;
34 import android.util.AttributeSet;
35 import android.view.View;
36 
37 import androidx.annotation.Px;
38 import androidx.annotation.RestrictTo;
39 import androidx.annotation.RestrictTo.Scope;
40 import androidx.core.view.ViewCompat;
41 import androidx.wear.R;
42 
43 import org.jspecify.annotations.NonNull;
44 
45 import java.util.Objects;
46 
47 /**
48  * An image view surrounded by a circle.
49  *
50  */
51 @RestrictTo(Scope.LIBRARY)
52 public class CircledImageView extends View {
53 
54     private static final ArgbEvaluator ARGB_EVALUATOR = new ArgbEvaluator();
55 
56     private static final int SQUARE_DIMEN_NONE = 0;
57     private static final int SQUARE_DIMEN_HEIGHT = 1;
58     private static final int SQUARE_DIMEN_WIDTH = 2;
59 
60     private final RectF mOval;
61     private final Paint mPaint;
62     private final OvalShadowPainter mShadowPainter;
63     private final float mInitialCircleRadius;
64     private final ProgressDrawable mIndeterminateDrawable;
65     private final Rect mIndeterminateBounds = new Rect();
66     private final Drawable.Callback mDrawableCallback =
67             new Drawable.Callback() {
68                 @Override
69                 public void invalidateDrawable(Drawable drawable) {
70                     invalidate();
71                 }
72 
73                 @Override
74                 public void scheduleDrawable(Drawable drawable, Runnable runnable, long l) {
75                     // Not needed.
76                 }
77 
78                 @Override
79                 public void unscheduleDrawable(Drawable drawable, Runnable runnable) {
80                     // Not needed.
81                 }
82             };
83     private ColorStateList mCircleColor;
84     private Drawable mDrawable;
85     private float mCircleRadius;
86     private float mCircleRadiusPercent;
87     private float mCircleRadiusPressed;
88     private float mCircleRadiusPressedPercent;
89     private float mRadiusInset;
90     private int mCircleBorderColor;
91     private Paint.Cap mCircleBorderCap;
92     private float mCircleBorderWidth;
93     private boolean mCircleHidden = false;
94     private float mProgress = 1f;
95     private boolean mPressed = false;
96     private boolean mProgressIndeterminate;
97     private boolean mVisible;
98     private boolean mWindowVisible;
99     private long mColorChangeAnimationDurationMs = 0;
100     private float mImageCirclePercentage = 1f;
101     private float mImageHorizontalOffcenterPercentage = 0f;
102     private Integer mImageTint;
103     private Integer mSquareDimen;
104     int mCurrentColor;
105 
106     private final AnimatorUpdateListener mAnimationListener =
107             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         ViewCompat.saveAttributeDataForStyleable(this, context, R.styleable.CircledImageView,
133                 attrs, a, 0, 0);
134         mDrawable = a.getDrawable(R.styleable.CircledImageView_android_src);
135         if (mDrawable != null && mDrawable.getConstantState() != null) {
136             // The provided Drawable may be used elsewhere, so make a mutable clone before setTint()
137             // or setAlpha() is called on it.
138             mDrawable =
139                     mDrawable.getConstantState()
140                             .newDrawable(context.getResources(), context.getTheme());
141             mDrawable = mDrawable.mutate();
142         }
143 
144         mCircleColor = a.getColorStateList(R.styleable.CircledImageView_background_color);
145         if (mCircleColor == null) {
146             mCircleColor = ColorStateList.valueOf(context.getColor(android.R.color.darker_gray));
147         }
148 
149         mCircleRadius = a.getDimension(R.styleable.CircledImageView_background_radius, 0);
150         mInitialCircleRadius = mCircleRadius;
151         mCircleRadiusPressed = a.getDimension(
152                 R.styleable.CircledImageView_background_radius_pressed, mCircleRadius);
153         mCircleBorderColor = a
154                 .getColor(R.styleable.CircledImageView_background_border_color, Color.BLACK);
155         mCircleBorderCap =
156                 Paint.Cap.values()[a.getInt(R.styleable.CircledImageView_background_border_cap, 0)];
157         mCircleBorderWidth = a.getDimension(
158                 R.styleable.CircledImageView_background_border_width, 0);
159 
160         if (mCircleBorderWidth > 0) {
161             // The border arc is drawn from the middle of the arc - take that into account.
162             mRadiusInset += mCircleBorderWidth / 2;
163         }
164 
165         float circlePadding = a.getDimension(R.styleable.CircledImageView_img_padding, 0);
166         if (circlePadding > 0) {
167             mRadiusInset += circlePadding;
168         }
169 
170         mImageCirclePercentage = a
171                 .getFloat(R.styleable.CircledImageView_img_circle_percentage, 0f);
172 
173         mImageHorizontalOffcenterPercentage =
174                 a.getFloat(R.styleable.CircledImageView_img_horizontal_offset_percentage, 0f);
175 
176         if (a.hasValue(R.styleable.CircledImageView_img_tint)) {
177             mImageTint = a.getColor(R.styleable.CircledImageView_img_tint, 0);
178         }
179 
180         if (a.hasValue(R.styleable.CircledImageView_clip_dimen)) {
181             mSquareDimen = a.getInt(R.styleable.CircledImageView_clip_dimen, SQUARE_DIMEN_NONE);
182         }
183 
184         mCircleRadiusPercent =
185                 a.getFraction(R.styleable.CircledImageView_background_radius_percent, 1, 1, 0f);
186 
187         mCircleRadiusPressedPercent =
188                 a.getFraction(
189                         R.styleable.CircledImageView_background_radius_pressed_percent, 1, 1,
190                         mCircleRadiusPercent);
191 
192         float shadowWidth = a.getDimension(R.styleable.CircledImageView_background_shadow_width, 0);
193 
194         a.recycle();
195 
196         mOval = new RectF();
197         mPaint = new Paint();
198         mPaint.setAntiAlias(true);
199         mShadowPainter = new OvalShadowPainter(shadowWidth, 0, getCircleRadius(),
200                 mCircleBorderWidth);
201 
202         mIndeterminateDrawable = new ProgressDrawable();
203         // {@link #mDrawableCallback} must be retained as a member, as Drawable callback
204         // is held by weak reference, we must retain it for it to continue to be called.
205         mIndeterminateDrawable.setCallback(mDrawableCallback);
206 
207         setWillNotDraw(false);
208 
209         setColorForCurrentState();
210     }
211 
212     /** Sets the circle to be hidden. */
setCircleHidden(boolean circleHidden)213     public void setCircleHidden(boolean circleHidden) {
214         if (circleHidden != mCircleHidden) {
215             mCircleHidden = circleHidden;
216             invalidate();
217         }
218     }
219 
220     @Override
onSetAlpha(int alpha)221     protected boolean onSetAlpha(int alpha) {
222         return true;
223     }
224 
225     @Override
onDraw(@onNull Canvas canvas)226     protected void onDraw(@NonNull Canvas canvas) {
227         int paddingLeft = getPaddingLeft();
228         int paddingTop = getPaddingTop();
229 
230         float circleRadius = mPressed ? getCircleRadiusPressed() : getCircleRadius();
231 
232         // Maybe draw the shadow
233         mShadowPainter.draw(canvas, getAlpha());
234         if (mCircleBorderWidth > 0) {
235             // First let's find the center of the view.
236             mOval.set(
237                     paddingLeft,
238                     paddingTop,
239                     getWidth() - getPaddingRight(),
240                     getHeight() - getPaddingBottom());
241             // Having the center, lets make the border meet the circle.
242             mOval.set(
243                     mOval.centerX() - circleRadius,
244                     mOval.centerY() - circleRadius,
245                     mOval.centerX() + circleRadius,
246                     mOval.centerY() + circleRadius);
247             mPaint.setColor(mCircleBorderColor);
248             // {@link #Paint.setAlpha} is a helper method that just sets the alpha portion of the
249             // color. {@link #Paint.setPaint} will clear any previously set alpha value.
250             mPaint.setAlpha(Math.round(mPaint.getAlpha() * getAlpha()));
251             mPaint.setStyle(Style.STROKE);
252             mPaint.setStrokeWidth(mCircleBorderWidth);
253             mPaint.setStrokeCap(mCircleBorderCap);
254 
255             if (mProgressIndeterminate) {
256                 mOval.roundOut(mIndeterminateBounds);
257                 mIndeterminateDrawable.setBounds(mIndeterminateBounds);
258                 mIndeterminateDrawable.setRingColor(mCircleBorderColor);
259                 mIndeterminateDrawable.setRingWidth(mCircleBorderWidth);
260                 mIndeterminateDrawable.draw(canvas);
261             } else {
262                 canvas.drawArc(mOval, -90, 360 * mProgress, false, mPaint);
263             }
264         }
265         if (!mCircleHidden) {
266             mOval.set(
267                     paddingLeft,
268                     paddingTop,
269                     getWidth() - getPaddingRight(),
270                     getHeight() - getPaddingBottom());
271             // {@link #Paint.setAlpha} is a helper method that just sets the alpha portion of the
272             // color. {@link #Paint.setPaint} will clear any previously set alpha value.
273             mPaint.setColor(mCurrentColor);
274             mPaint.setAlpha(Math.round(mPaint.getAlpha() * getAlpha()));
275 
276             mPaint.setStyle(Style.FILL);
277             float centerX = mOval.centerX();
278             float centerY = mOval.centerY();
279 
280             canvas.drawCircle(centerX, centerY, circleRadius, mPaint);
281         }
282 
283         if (mDrawable != null) {
284             mDrawable.setAlpha(Math.round(getAlpha() * 255));
285 
286             if (mImageTint != null) {
287                 mDrawable.setTint(mImageTint);
288             }
289             mDrawable.draw(canvas);
290         }
291 
292         super.onDraw(canvas);
293     }
294 
setColorForCurrentState()295     private void setColorForCurrentState() {
296         int newColor =
297                 mCircleColor.getColorForState(getDrawableState(), mCircleColor.getDefaultColor());
298         if (mColorChangeAnimationDurationMs > 0) {
299             if (mColorAnimator != null) {
300                 mColorAnimator.cancel();
301             } else {
302                 mColorAnimator = new ValueAnimator();
303             }
304             mColorAnimator.setIntValues(new int[]{mCurrentColor, newColor});
305             mColorAnimator.setEvaluator(ARGB_EVALUATOR);
306             mColorAnimator.setDuration(mColorChangeAnimationDurationMs);
307             mColorAnimator.addUpdateListener(this.mAnimationListener);
308             mColorAnimator.start();
309         } else {
310             if (newColor != mCurrentColor) {
311                 mCurrentColor = newColor;
312                 invalidate();
313             }
314         }
315     }
316 
317     @Override
onMeasure(int widthMeasureSpec, int heightMeasureSpec)318     protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
319 
320         final float radius =
321                 getCircleRadius()
322                         + mCircleBorderWidth
323                         + mShadowPainter.mShadowWidth * mShadowPainter.mShadowVisibility;
324         float desiredWidth = radius * 2;
325         float desiredHeight = radius * 2;
326 
327         int widthMode = MeasureSpec.getMode(widthMeasureSpec);
328         int widthSize = MeasureSpec.getSize(widthMeasureSpec);
329         int heightMode = MeasureSpec.getMode(heightMeasureSpec);
330         int heightSize = MeasureSpec.getSize(heightMeasureSpec);
331 
332         int width;
333         int height;
334 
335         if (widthMode == MeasureSpec.EXACTLY) {
336             width = widthSize;
337         } else if (widthMode == MeasureSpec.AT_MOST) {
338             width = (int) Math.min(desiredWidth, widthSize);
339         } else {
340             width = (int) desiredWidth;
341         }
342 
343         if (heightMode == MeasureSpec.EXACTLY) {
344             height = heightSize;
345         } else if (heightMode == MeasureSpec.AT_MOST) {
346             height = (int) Math.min(desiredHeight, heightSize);
347         } else {
348             height = (int) desiredHeight;
349         }
350 
351         if (mSquareDimen != null) {
352             switch (mSquareDimen) {
353                 case SQUARE_DIMEN_HEIGHT:
354                     width = height;
355                     break;
356                 case SQUARE_DIMEN_WIDTH:
357                     height = width;
358                     break;
359             }
360         }
361 
362         super.onMeasure(
363                 MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
364                 MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY));
365     }
366 
367     @Override
onLayout(boolean changed, int left, int top, int right, int bottom)368     protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
369         if (mDrawable != null) {
370             // Retrieve the sizes of the drawable and the view.
371             final int nativeDrawableWidth = mDrawable.getIntrinsicWidth();
372             final int nativeDrawableHeight = mDrawable.getIntrinsicHeight();
373             final int viewWidth = getMeasuredWidth();
374             final int viewHeight = getMeasuredHeight();
375             final float imageCirclePercentage =
376                     mImageCirclePercentage > 0 ? mImageCirclePercentage : 1;
377 
378             final float scaleFactor =
379                     Math.min(
380                             1f,
381                             Math.min(
382                                     (float) nativeDrawableWidth != 0
383                                             ? imageCirclePercentage * viewWidth
384                                             / nativeDrawableWidth
385                                             : 1,
386                                     (float) nativeDrawableHeight != 0
387                                             ? imageCirclePercentage * viewHeight
388                                             / nativeDrawableHeight
389                                             : 1));
390 
391             // Scale the drawable down to fit the view, if needed.
392             final int drawableWidth = Math.round(scaleFactor * nativeDrawableWidth);
393             final int drawableHeight = Math.round(scaleFactor * nativeDrawableHeight);
394 
395             // Center the drawable within the view.
396             final int drawableLeft =
397                     (viewWidth - drawableWidth) / 2
398                             + Math.round(mImageHorizontalOffcenterPercentage * drawableWidth);
399             final int drawableTop = (viewHeight - drawableHeight) / 2;
400 
401             mDrawable.setBounds(
402                     drawableLeft, drawableTop, drawableLeft + drawableWidth,
403                     drawableTop + drawableHeight);
404         }
405 
406         super.onLayout(changed, left, top, right, bottom);
407     }
408 
409     /** Sets the image given a resource. */
setImageResource(int resId)410     public void setImageResource(int resId) {
411         setImageDrawable(resId == 0 ? null : getContext().getDrawable(resId));
412     }
413 
414     /** Sets the size of the image based on a percentage in [0, 1]. */
setImageCirclePercentage(float percentage)415     public void setImageCirclePercentage(float percentage) {
416         float clamped = Math.max(0, Math.min(1, percentage));
417         if (clamped != mImageCirclePercentage) {
418             mImageCirclePercentage = clamped;
419             invalidate();
420         }
421     }
422 
423     /** Sets the horizontal offset given a percentage in [0, 1]. */
setImageHorizontalOffcenterPercentage(float percentage)424     public void setImageHorizontalOffcenterPercentage(float percentage) {
425         if (percentage != mImageHorizontalOffcenterPercentage) {
426             mImageHorizontalOffcenterPercentage = percentage;
427             invalidate();
428         }
429     }
430 
431     /** Sets the tint. */
setImageTint(int tint)432     public void setImageTint(int tint) {
433         if (mImageTint == null || tint != mImageTint) {
434             mImageTint = tint;
435             invalidate();
436         }
437     }
438 
439     /** Returns the circle radius. */
getCircleRadius()440     public float getCircleRadius() {
441         float radius = mCircleRadius;
442         if (mCircleRadius <= 0 && mCircleRadiusPercent > 0) {
443             radius = Math.max(getMeasuredHeight(), getMeasuredWidth()) * mCircleRadiusPercent;
444         }
445 
446         return radius - mRadiusInset;
447     }
448 
449     /** Sets the circle radius. */
setCircleRadius(float circleRadius)450     public void setCircleRadius(float circleRadius) {
451         if (circleRadius != mCircleRadius) {
452             mCircleRadius = circleRadius;
453             mShadowPainter
454                     .setInnerCircleRadius(mPressed ? getCircleRadiusPressed() : getCircleRadius());
455             invalidate();
456         }
457     }
458 
459     /** Gets the circle radius percent. */
getCircleRadiusPercent()460     public float getCircleRadiusPercent() {
461         return mCircleRadiusPercent;
462     }
463 
464     /**
465      * Sets the radius of the circle to be a percentage of the largest dimension of the view.
466      *
467      * @param circleRadiusPercent A {@code float} from 0 to 1 representing the radius percentage.
468      */
setCircleRadiusPercent(float circleRadiusPercent)469     public void setCircleRadiusPercent(float circleRadiusPercent) {
470         if (circleRadiusPercent != mCircleRadiusPercent) {
471             mCircleRadiusPercent = circleRadiusPercent;
472             mShadowPainter
473                     .setInnerCircleRadius(mPressed ? getCircleRadiusPressed() : getCircleRadius());
474             invalidate();
475         }
476     }
477 
478     /** Gets the circle radius when pressed. */
getCircleRadiusPressed()479     public float getCircleRadiusPressed() {
480         float radius = mCircleRadiusPressed;
481 
482         if (mCircleRadiusPressed <= 0 && mCircleRadiusPressedPercent > 0) {
483             radius =
484                     Math.max(getMeasuredHeight(), getMeasuredWidth()) * mCircleRadiusPressedPercent;
485         }
486 
487         return radius - mRadiusInset;
488     }
489 
490     /** Sets the circle radius when pressed. */
setCircleRadiusPressed(float circleRadiusPressed)491     public void setCircleRadiusPressed(float circleRadiusPressed) {
492         if (circleRadiusPressed != mCircleRadiusPressed) {
493             mCircleRadiusPressed = circleRadiusPressed;
494             invalidate();
495         }
496     }
497 
498     /** Gets the circle radius when pressed as a percent. */
getCircleRadiusPressedPercent()499     public float getCircleRadiusPressedPercent() {
500         return mCircleRadiusPressedPercent;
501     }
502 
503     /**
504      * Sets the radius of the circle to be a percentage of the largest dimension of the view when
505      * pressed.
506      *
507      * @param circleRadiusPressedPercent A {@code float} from 0 to 1 representing the radius
508      * percentage.
509      */
setCircleRadiusPressedPercent(float circleRadiusPressedPercent)510     public void setCircleRadiusPressedPercent(float circleRadiusPressedPercent) {
511         if (circleRadiusPressedPercent != mCircleRadiusPressedPercent) {
512             mCircleRadiusPressedPercent = circleRadiusPressedPercent;
513             mShadowPainter
514                     .setInnerCircleRadius(mPressed ? getCircleRadiusPressed() : getCircleRadius());
515             invalidate();
516         }
517     }
518 
519     @Override
drawableStateChanged()520     protected void drawableStateChanged() {
521         super.drawableStateChanged();
522         setColorForCurrentState();
523     }
524 
525     /** Sets the circle color. */
setCircleColor(int circleColor)526     public void setCircleColor(int circleColor) {
527         setCircleColorStateList(ColorStateList.valueOf(circleColor));
528     }
529 
530     /** Gets the circle color. */
getCircleColorStateList()531     public ColorStateList getCircleColorStateList() {
532         return mCircleColor;
533     }
534 
535     /** Sets the circle color. */
setCircleColorStateList(ColorStateList circleColor)536     public void setCircleColorStateList(ColorStateList circleColor) {
537         if (!Objects.equals(circleColor, mCircleColor)) {
538             mCircleColor = circleColor;
539             setColorForCurrentState();
540             invalidate();
541         }
542     }
543 
544     /** Gets the default circle color. */
getDefaultCircleColor()545     public int getDefaultCircleColor() {
546         return mCircleColor.getDefaultColor();
547     }
548 
549     /**
550      * Show the circle border as an indeterminate progress spinner. The views circle border width
551      * and color must be set for this to have an effect.
552      *
553      * @param show true if the progress spinner is shown, false to hide it.
554      */
showIndeterminateProgress(boolean show)555     public void showIndeterminateProgress(boolean show) {
556         mProgressIndeterminate = show;
557         if (mIndeterminateDrawable != null) {
558             if (show && mVisible && mWindowVisible) {
559                 mIndeterminateDrawable.startAnimation();
560             } else {
561                 mIndeterminateDrawable.stopAnimation();
562             }
563         }
564     }
565 
566     @Override
onVisibilityChanged(View changedView, int visibility)567     protected void onVisibilityChanged(View changedView, int visibility) {
568         super.onVisibilityChanged(changedView, visibility);
569         mVisible = (visibility == View.VISIBLE);
570         showIndeterminateProgress(mProgressIndeterminate);
571     }
572 
573     @Override
onWindowVisibilityChanged(int visibility)574     protected void onWindowVisibilityChanged(int visibility) {
575         super.onWindowVisibilityChanged(visibility);
576         mWindowVisible = (visibility == View.VISIBLE);
577         showIndeterminateProgress(mProgressIndeterminate);
578     }
579 
580     /** Sets the progress. */
setProgress(float progress)581     public void setProgress(float progress) {
582         if (progress != mProgress) {
583             mProgress = progress;
584             invalidate();
585         }
586     }
587 
588     /**
589      * Set how much of the shadow should be shown.
590      *
591      * @param shadowVisibility Value between 0 and 1.
592      */
setShadowVisibility(float shadowVisibility)593     public void setShadowVisibility(float shadowVisibility) {
594         if (shadowVisibility != mShadowPainter.mShadowVisibility) {
595             mShadowPainter.setShadowVisibility(shadowVisibility);
596             invalidate();
597         }
598     }
599 
getInitialCircleRadius()600     public float getInitialCircleRadius() {
601         return mInitialCircleRadius;
602     }
603 
setCircleBorderColor(int circleBorderColor)604     public void setCircleBorderColor(int circleBorderColor) {
605         mCircleBorderColor = circleBorderColor;
606     }
607 
608     /**
609      * Set the border around the circle.
610      *
611      * @param circleBorderWidth Width of the border around the circle.
612      */
setCircleBorderWidth(float circleBorderWidth)613     public void setCircleBorderWidth(float circleBorderWidth) {
614         if (circleBorderWidth != mCircleBorderWidth) {
615             mCircleBorderWidth = circleBorderWidth;
616             mShadowPainter.setInnerCircleBorderWidth(circleBorderWidth);
617             invalidate();
618         }
619     }
620 
621     /**
622      * Set the stroke cap for the border around the circle.
623      *
624      * @param circleBorderCap Stroke cap for the border around the circle.
625      */
setCircleBorderCap(Paint.Cap circleBorderCap)626     public void setCircleBorderCap(Paint.Cap circleBorderCap) {
627         if (circleBorderCap != mCircleBorderCap) {
628             mCircleBorderCap = circleBorderCap;
629             invalidate();
630         }
631     }
632 
633     @Override
setPressed(boolean pressed)634     public void setPressed(boolean pressed) {
635         super.setPressed(pressed);
636         if (pressed != mPressed) {
637             mPressed = pressed;
638             mShadowPainter
639                     .setInnerCircleRadius(mPressed ? getCircleRadiusPressed() : getCircleRadius());
640             invalidate();
641         }
642     }
643 
644     @Override
setPadding(@x int left, @Px int top, @Px int right, @Px int bottom)645     public void setPadding(@Px int left, @Px int top, @Px int right, @Px int bottom) {
646         if (left != getPaddingLeft()
647                 || top != getPaddingTop()
648                 || right != getPaddingRight()
649                 || bottom != getPaddingBottom()) {
650             mShadowPainter.setBounds(left, top, getWidth() - right, getHeight() - bottom);
651         }
652         super.setPadding(left, top, right, bottom);
653     }
654 
655     @Override
onSizeChanged(int newWidth, int newHeight, int oldWidth, int oldHeight)656     public void onSizeChanged(int newWidth, int newHeight, int oldWidth, int oldHeight) {
657         if (newWidth != oldWidth || newHeight != oldHeight) {
658             mShadowPainter.setBounds(
659                     getPaddingLeft(),
660                     getPaddingTop(),
661                     newWidth - getPaddingRight(),
662                     newHeight - getPaddingBottom());
663         }
664     }
665 
getImageDrawable()666     public Drawable getImageDrawable() {
667         return mDrawable;
668     }
669 
670     /** Sets the image drawable. */
setImageDrawable(Drawable drawable)671     public void setImageDrawable(Drawable drawable) {
672         if (drawable != mDrawable) {
673             final Drawable existingDrawable = mDrawable;
674             mDrawable = drawable;
675             if (mDrawable != null && mDrawable.getConstantState() != null) {
676                 // The provided Drawable may be used elsewhere, so make a mutable clone before
677                 // setTint() or setAlpha() is called on it.
678                 mDrawable =
679                         mDrawable
680                                 .getConstantState()
681                                 .newDrawable(getResources(), getContext().getTheme())
682                                 .mutate();
683             }
684 
685             final boolean skipLayout =
686                     drawable != null
687                             && existingDrawable != null
688                             && existingDrawable.getIntrinsicHeight() == drawable
689                             .getIntrinsicHeight()
690                             && existingDrawable.getIntrinsicWidth() == drawable.getIntrinsicWidth();
691 
692             if (skipLayout) {
693                 mDrawable.setBounds(existingDrawable.getBounds());
694             } else {
695                 requestLayout();
696             }
697 
698             invalidate();
699         }
700     }
701 
702     /**
703      * @return the milliseconds duration of the transition animation when the color changes.
704      */
getColorChangeAnimationDuration()705     public long getColorChangeAnimationDuration() {
706         return mColorChangeAnimationDurationMs;
707     }
708 
709     /**
710      * @param mColorChangeAnimationDurationMs the milliseconds duration of the color change
711      * animation. The color change animation will run if the color changes with {@link
712      * #setCircleColor} or as a result of the active state changing.
713      */
setColorChangeAnimationDuration(long mColorChangeAnimationDurationMs)714     public void setColorChangeAnimationDuration(long mColorChangeAnimationDurationMs) {
715         this.mColorChangeAnimationDurationMs = mColorChangeAnimationDurationMs;
716     }
717 
718     /**
719      * Helper class taking care of painting a shadow behind the displayed image. TODO(amad): Replace
720      * this with elevation, when moving to support/wearable?
721      */
722     private static class OvalShadowPainter {
723 
724         private final int[] mShaderColors = new int[]{Color.BLACK, Color.TRANSPARENT};
725         private final float[] mShaderStops = new float[]{0.6f, 1f};
726         private final RectF mBounds = new RectF();
727         final float mShadowWidth;
728         private final Paint mShadowPaint = new Paint();
729 
730         private float mShadowRadius;
731         float mShadowVisibility;
732         private float mInnerCircleRadius;
733         private float mInnerCircleBorderWidth;
734 
OvalShadowPainter( float shadowWidth, float shadowVisibility, float innerCircleRadius, float innerCircleBorderWidth)735         OvalShadowPainter(
736                 float shadowWidth,
737                 float shadowVisibility,
738                 float innerCircleRadius,
739                 float innerCircleBorderWidth) {
740             mShadowWidth = shadowWidth;
741             mShadowVisibility = shadowVisibility;
742             mInnerCircleRadius = innerCircleRadius;
743             mInnerCircleBorderWidth = innerCircleBorderWidth;
744             mShadowRadius =
745                     mInnerCircleRadius + mInnerCircleBorderWidth + mShadowWidth * mShadowVisibility;
746             mShadowPaint.setColor(Color.BLACK);
747             mShadowPaint.setStyle(Style.FILL);
748             mShadowPaint.setAntiAlias(true);
749             updateRadialGradient();
750         }
751 
draw(Canvas canvas, float alpha)752         void draw(Canvas canvas, float alpha) {
753             if (mShadowWidth > 0 && mShadowVisibility > 0) {
754                 mShadowPaint.setAlpha(Math.round(mShadowPaint.getAlpha() * alpha));
755                 canvas.drawCircle(mBounds.centerX(), mBounds.centerY(), mShadowRadius,
756                         mShadowPaint);
757             }
758         }
759 
setBounds(@x int left, @Px int top, @Px int right, @Px int bottom)760         void setBounds(@Px int left, @Px int top, @Px int right, @Px int bottom) {
761             mBounds.set(left, top, right, bottom);
762             updateRadialGradient();
763         }
764 
setInnerCircleRadius(float newInnerCircleRadius)765         void setInnerCircleRadius(float newInnerCircleRadius) {
766             mInnerCircleRadius = newInnerCircleRadius;
767             updateRadialGradient();
768         }
769 
setInnerCircleBorderWidth(float newInnerCircleBorderWidth)770         void setInnerCircleBorderWidth(float newInnerCircleBorderWidth) {
771             mInnerCircleBorderWidth = newInnerCircleBorderWidth;
772             updateRadialGradient();
773         }
774 
setShadowVisibility(float newShadowVisibility)775         void setShadowVisibility(float newShadowVisibility) {
776             mShadowVisibility = newShadowVisibility;
777             updateRadialGradient();
778         }
779 
updateRadialGradient()780         private void updateRadialGradient() {
781             // Make the shadow start beyond the circled and possibly the border.
782             mShadowRadius =
783                     mInnerCircleRadius + mInnerCircleBorderWidth + mShadowWidth * mShadowVisibility;
784             // This may happen if the innerCircleRadius has not been correctly computed yet while
785             // the view has already been inflated, but not yet measured. In this case, if the view
786             // specifies the radius as a percentage of the screen width, then that evaluates to 0
787             // and will be corrected after measuring, through onSizeChanged().
788             if (mShadowRadius > 0) {
789                 mShadowPaint.setShader(
790                         new RadialGradient(
791                                 mBounds.centerX(),
792                                 mBounds.centerY(),
793                                 mShadowRadius,
794                                 mShaderColors,
795                                 mShaderStops,
796                                 Shader.TileMode.MIRROR));
797             }
798         }
799     }
800 }
801