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