1/* 2 * Copyright (C) 2014 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17package android.support.v4.widget; 18 19import android.view.animation.AccelerateDecelerateInterpolator; 20import android.view.animation.Interpolator; 21import android.view.animation.Animation; 22import android.view.animation.LinearInterpolator; 23import android.view.animation.Transformation; 24import android.content.Context; 25import android.content.res.Resources; 26import android.graphics.Canvas; 27import android.graphics.Color; 28import android.graphics.ColorFilter; 29import android.graphics.Paint; 30import android.graphics.Paint.Style; 31import android.graphics.Path; 32import android.graphics.PixelFormat; 33import android.graphics.Rect; 34import android.graphics.RectF; 35import android.graphics.drawable.Drawable; 36import android.graphics.drawable.Animatable; 37import android.support.annotation.IntDef; 38import android.support.annotation.NonNull; 39import android.util.DisplayMetrics; 40import android.view.View; 41 42import java.lang.annotation.Retention; 43import java.lang.annotation.RetentionPolicy; 44import java.util.ArrayList; 45 46/** 47 * Fancy progress indicator for Material theme. 48 * 49 * @hide 50 */ 51class MaterialProgressDrawable extends Drawable implements Animatable { 52 private static final Interpolator LINEAR_INTERPOLATOR = new LinearInterpolator(); 53 private static final Interpolator END_CURVE_INTERPOLATOR = new EndCurveInterpolator(); 54 private static final Interpolator START_CURVE_INTERPOLATOR = new StartCurveInterpolator(); 55 private static final Interpolator EASE_INTERPOLATOR = new AccelerateDecelerateInterpolator(); 56 57 @Retention(RetentionPolicy.CLASS) 58 @IntDef({LARGE, DEFAULT}) 59 public @interface ProgressDrawableSize {} 60 // Maps to ProgressBar.Large style 61 static final int LARGE = 0; 62 // Maps to ProgressBar default style 63 static final int DEFAULT = 1; 64 65 // Maps to ProgressBar default style 66 private static final int CIRCLE_DIAMETER = 40; 67 private static final float CENTER_RADIUS = 8.75f; //should add up to 10 when + stroke_width 68 private static final float STROKE_WIDTH = 2.5f; 69 70 // Maps to ProgressBar.Large style 71 private static final int CIRCLE_DIAMETER_LARGE = 56; 72 private static final float CENTER_RADIUS_LARGE = 12.5f; 73 private static final float STROKE_WIDTH_LARGE = 3f; 74 75 private final int[] COLORS = new int[] { 76 Color.BLACK 77 }; 78 79 /** The duration of a single progress spin in milliseconds. */ 80 private static final int ANIMATION_DURATION = 1000 * 80 / 60; 81 82 /** The number of points in the progress "star". */ 83 private static final float NUM_POINTS = 5f; 84 /** The list of animators operating on this drawable. */ 85 private final ArrayList<Animation> mAnimators = new ArrayList<Animation>(); 86 87 /** The indicator ring, used to manage animation state. */ 88 private final Ring mRing; 89 90 /** Canvas rotation in degrees. */ 91 private float mRotation; 92 93 /** Layout info for the arrowhead in dp */ 94 private static final int ARROW_WIDTH = 10; 95 private static final int ARROW_HEIGHT = 5; 96 private static final float ARROW_OFFSET_ANGLE = 5; 97 98 /** Layout info for the arrowhead for the large spinner in dp */ 99 private static final int ARROW_WIDTH_LARGE = 12; 100 private static final int ARROW_HEIGHT_LARGE = 6; 101 private static final float MAX_PROGRESS_ARC = .8f; 102 103 private Resources mResources; 104 private View mParent; 105 private Animation mAnimation; 106 private float mRotationCount; 107 private double mWidth; 108 private double mHeight; 109 boolean mFinishing; 110 111 public MaterialProgressDrawable(Context context, View parent) { 112 mParent = parent; 113 mResources = context.getResources(); 114 115 mRing = new Ring(mCallback); 116 mRing.setColors(COLORS); 117 118 updateSizes(DEFAULT); 119 setupAnimators(); 120 } 121 122 private void setSizeParameters(double progressCircleWidth, double progressCircleHeight, 123 double centerRadius, double strokeWidth, float arrowWidth, float arrowHeight) { 124 final Ring ring = mRing; 125 final DisplayMetrics metrics = mResources.getDisplayMetrics(); 126 final float screenDensity = metrics.density; 127 128 mWidth = progressCircleWidth * screenDensity; 129 mHeight = progressCircleHeight * screenDensity; 130 ring.setStrokeWidth((float) strokeWidth * screenDensity); 131 ring.setCenterRadius(centerRadius * screenDensity); 132 ring.setColorIndex(0); 133 ring.setArrowDimensions(arrowWidth * screenDensity, arrowHeight * screenDensity); 134 ring.setInsets((int) mWidth, (int) mHeight); 135 } 136 137 /** 138 * Set the overall size for the progress spinner. This updates the radius 139 * and stroke width of the ring. 140 * 141 * @param size One of {@link MaterialProgressDrawable.LARGE} or 142 * {@link MaterialProgressDrawable.DEFAULT} 143 */ 144 public void updateSizes(@ProgressDrawableSize int size) { 145 if (size == LARGE) { 146 setSizeParameters(CIRCLE_DIAMETER_LARGE, CIRCLE_DIAMETER_LARGE, CENTER_RADIUS_LARGE, 147 STROKE_WIDTH_LARGE, ARROW_WIDTH_LARGE, ARROW_HEIGHT_LARGE); 148 } else { 149 setSizeParameters(CIRCLE_DIAMETER, CIRCLE_DIAMETER, CENTER_RADIUS, STROKE_WIDTH, 150 ARROW_WIDTH, ARROW_HEIGHT); 151 } 152 } 153 154 /** 155 * @param show Set to true to display the arrowhead on the progress spinner. 156 */ 157 public void showArrow(boolean show) { 158 mRing.setShowArrow(show); 159 } 160 161 /** 162 * @param scale Set the scale of the arrowhead for the spinner. 163 */ 164 public void setArrowScale(float scale) { 165 mRing.setArrowScale(scale); 166 } 167 168 /** 169 * Set the start and end trim for the progress spinner arc. 170 * 171 * @param startAngle start angle 172 * @param endAngle end angle 173 */ 174 public void setStartEndTrim(float startAngle, float endAngle) { 175 mRing.setStartTrim(startAngle); 176 mRing.setEndTrim(endAngle); 177 } 178 179 /** 180 * Set the amount of rotation to apply to the progress spinner. 181 * 182 * @param rotation Rotation is from [0..1] 183 */ 184 public void setProgressRotation(float rotation) { 185 mRing.setRotation(rotation); 186 } 187 188 /** 189 * Update the background color of the circle image view. 190 */ 191 public void setBackgroundColor(int color) { 192 mRing.setBackgroundColor(color); 193 } 194 195 /** 196 * Set the colors used in the progress animation from color resources. 197 * The first color will also be the color of the bar that grows in response 198 * to a user swipe gesture. 199 * 200 * @param colors 201 */ 202 public void setColorSchemeColors(int... colors) { 203 mRing.setColors(colors); 204 mRing.setColorIndex(0); 205 } 206 207 @Override 208 public int getIntrinsicHeight() { 209 return (int) mHeight; 210 } 211 212 @Override 213 public int getIntrinsicWidth() { 214 return (int) mWidth; 215 } 216 217 @Override 218 public void draw(Canvas c) { 219 final Rect bounds = getBounds(); 220 final int saveCount = c.save(); 221 c.rotate(mRotation, bounds.exactCenterX(), bounds.exactCenterY()); 222 mRing.draw(c, bounds); 223 c.restoreToCount(saveCount); 224 } 225 226 @Override 227 public void setAlpha(int alpha) { 228 mRing.setAlpha(alpha); 229 } 230 231 public int getAlpha() { 232 return mRing.getAlpha(); 233 } 234 235 @Override 236 public void setColorFilter(ColorFilter colorFilter) { 237 mRing.setColorFilter(colorFilter); 238 } 239 240 @SuppressWarnings("unused") 241 void setRotation(float rotation) { 242 mRotation = rotation; 243 invalidateSelf(); 244 } 245 246 @SuppressWarnings("unused") 247 private float getRotation() { 248 return mRotation; 249 } 250 251 @Override 252 public int getOpacity() { 253 return PixelFormat.TRANSLUCENT; 254 } 255 256 @Override 257 public boolean isRunning() { 258 final ArrayList<Animation> animators = mAnimators; 259 final int N = animators.size(); 260 for (int i = 0; i < N; i++) { 261 final Animation animator = animators.get(i); 262 if (animator.hasStarted() && !animator.hasEnded()) { 263 return true; 264 } 265 } 266 return false; 267 } 268 269 @Override 270 public void start() { 271 mAnimation.reset(); 272 mRing.storeOriginals(); 273 // Already showing some part of the ring 274 if (mRing.getEndTrim() != mRing.getStartTrim()) { 275 mFinishing = true; 276 mAnimation.setDuration(ANIMATION_DURATION/2); 277 mParent.startAnimation(mAnimation); 278 } else { 279 mRing.setColorIndex(0); 280 mRing.resetOriginals(); 281 mAnimation.setDuration(ANIMATION_DURATION); 282 mParent.startAnimation(mAnimation); 283 } 284 } 285 286 @Override 287 public void stop() { 288 mParent.clearAnimation(); 289 setRotation(0); 290 mRing.setShowArrow(false); 291 mRing.setColorIndex(0); 292 mRing.resetOriginals(); 293 } 294 295 private void applyFinishTranslation(float interpolatedTime, Ring ring) { 296 // shrink back down and complete a full rotation before 297 // starting other circles 298 // Rotation goes between [0..1]. 299 float targetRotation = (float) (Math.floor(ring.getStartingRotation() / MAX_PROGRESS_ARC) 300 + 1f); 301 final float startTrim = ring.getStartingStartTrim() 302 + (ring.getStartingEndTrim() - ring.getStartingStartTrim()) * interpolatedTime; 303 ring.setStartTrim(startTrim); 304 final float rotation = ring.getStartingRotation() 305 + ((targetRotation - ring.getStartingRotation()) * interpolatedTime); 306 ring.setRotation(rotation); 307 } 308 309 private void setupAnimators() { 310 final Ring ring = mRing; 311 final Animation animation = new Animation() { 312 @Override 313 public void applyTransformation(float interpolatedTime, Transformation t) { 314 if (mFinishing) { 315 applyFinishTranslation(interpolatedTime, ring); 316 } else { 317 // The minProgressArc is calculated from 0 to create an 318 // angle that 319 // matches the stroke width. 320 final float minProgressArc = (float) Math.toRadians( 321 ring.getStrokeWidth() / (2 * Math.PI * ring.getCenterRadius())); 322 final float startingEndTrim = ring.getStartingEndTrim(); 323 final float startingTrim = ring.getStartingStartTrim(); 324 final float startingRotation = ring.getStartingRotation(); 325 326 // Offset the minProgressArc to where the endTrim is 327 // located. 328 final float minArc = MAX_PROGRESS_ARC - minProgressArc; 329 final float endTrim = startingEndTrim + (minArc 330 * START_CURVE_INTERPOLATOR.getInterpolation(interpolatedTime)); 331 ring.setEndTrim(endTrim); 332 333 final float startTrim = startingTrim + (MAX_PROGRESS_ARC 334 * END_CURVE_INTERPOLATOR.getInterpolation(interpolatedTime)); 335 ring.setStartTrim(startTrim); 336 337 final float rotation = startingRotation + (0.25f * interpolatedTime); 338 ring.setRotation(rotation); 339 340 float groupRotation = ((720.0f / NUM_POINTS) * interpolatedTime) 341 + (720.0f * (mRotationCount / NUM_POINTS)); 342 setRotation(groupRotation); 343 } 344 } 345 }; 346 animation.setRepeatCount(Animation.INFINITE); 347 animation.setRepeatMode(Animation.RESTART); 348 animation.setInterpolator(LINEAR_INTERPOLATOR); 349 animation.setAnimationListener(new Animation.AnimationListener() { 350 351 @Override 352 public void onAnimationStart(Animation animation) { 353 mRotationCount = 0; 354 } 355 356 @Override 357 public void onAnimationEnd(Animation animation) { 358 // do nothing 359 } 360 361 @Override 362 public void onAnimationRepeat(Animation animation) { 363 ring.storeOriginals(); 364 ring.goToNextColor(); 365 ring.setStartTrim(ring.getEndTrim()); 366 if (mFinishing) { 367 // finished closing the last ring from the swipe gesture; go 368 // into progress mode 369 mFinishing = false; 370 animation.setDuration(ANIMATION_DURATION); 371 ring.setShowArrow(false); 372 } else { 373 mRotationCount = (mRotationCount + 1) % (NUM_POINTS); 374 } 375 } 376 }); 377 mAnimation = animation; 378 } 379 380 private final Callback mCallback = new Callback() { 381 @Override 382 public void invalidateDrawable(Drawable d) { 383 invalidateSelf(); 384 } 385 386 @Override 387 public void scheduleDrawable(Drawable d, Runnable what, long when) { 388 scheduleSelf(what, when); 389 } 390 391 @Override 392 public void unscheduleDrawable(Drawable d, Runnable what) { 393 unscheduleSelf(what); 394 } 395 }; 396 397 private static class Ring { 398 private final RectF mTempBounds = new RectF(); 399 private final Paint mPaint = new Paint(); 400 private final Paint mArrowPaint = new Paint(); 401 402 private final Callback mCallback; 403 404 private float mStartTrim = 0.0f; 405 private float mEndTrim = 0.0f; 406 private float mRotation = 0.0f; 407 private float mStrokeWidth = 5.0f; 408 private float mStrokeInset = 2.5f; 409 410 private int[] mColors; 411 // mColorIndex represents the offset into the available mColors that the 412 // progress circle should currently display. As the progress circle is 413 // animating, the mColorIndex moves by one to the next available color. 414 private int mColorIndex; 415 private float mStartingStartTrim; 416 private float mStartingEndTrim; 417 private float mStartingRotation; 418 private boolean mShowArrow; 419 private Path mArrow; 420 private float mArrowScale; 421 private double mRingCenterRadius; 422 private int mArrowWidth; 423 private int mArrowHeight; 424 private int mAlpha; 425 private final Paint mCirclePaint = new Paint(); 426 private int mBackgroundColor; 427 428 public Ring(Callback callback) { 429 mCallback = callback; 430 431 mPaint.setStrokeCap(Paint.Cap.SQUARE); 432 mPaint.setAntiAlias(true); 433 mPaint.setStyle(Style.STROKE); 434 435 mArrowPaint.setStyle(Paint.Style.FILL); 436 mArrowPaint.setAntiAlias(true); 437 } 438 439 public void setBackgroundColor(int color) { 440 mBackgroundColor = color; 441 } 442 443 /** 444 * Set the dimensions of the arrowhead. 445 * 446 * @param width Width of the hypotenuse of the arrow head 447 * @param height Height of the arrow point 448 */ 449 public void setArrowDimensions(float width, float height) { 450 mArrowWidth = (int) width; 451 mArrowHeight = (int) height; 452 } 453 454 /** 455 * Draw the progress spinner 456 */ 457 public void draw(Canvas c, Rect bounds) { 458 final RectF arcBounds = mTempBounds; 459 arcBounds.set(bounds); 460 arcBounds.inset(mStrokeInset, mStrokeInset); 461 462 final float startAngle = (mStartTrim + mRotation) * 360; 463 final float endAngle = (mEndTrim + mRotation) * 360; 464 float sweepAngle = endAngle - startAngle; 465 466 mPaint.setColor(mColors[mColorIndex]); 467 c.drawArc(arcBounds, startAngle, sweepAngle, false, mPaint); 468 469 drawTriangle(c, startAngle, sweepAngle, bounds); 470 471 if (mAlpha < 255) { 472 mCirclePaint.setColor(mBackgroundColor); 473 mCirclePaint.setAlpha(255 - mAlpha); 474 c.drawCircle(bounds.exactCenterX(), bounds.exactCenterY(), bounds.width() / 2, 475 mCirclePaint); 476 } 477 } 478 479 private void drawTriangle(Canvas c, float startAngle, float sweepAngle, Rect bounds) { 480 if (mShowArrow) { 481 if (mArrow == null) { 482 mArrow = new android.graphics.Path(); 483 mArrow.setFillType(android.graphics.Path.FillType.EVEN_ODD); 484 } else { 485 mArrow.reset(); 486 } 487 488 // Adjust the position of the triangle so that it is inset as 489 // much as the arc, but also centered on the arc. 490 float inset = (int) mStrokeInset / 2 * mArrowScale; 491 float x = (float) (mRingCenterRadius * Math.cos(0) + bounds.exactCenterX()); 492 float y = (float) (mRingCenterRadius * Math.sin(0) + bounds.exactCenterY()); 493 494 // Update the path each time. This works around an issue in SKIA 495 // where concatenating a rotation matrix to a scale matrix 496 // ignored a starting negative rotation. This appears to have 497 // been fixed as of API 21. 498 mArrow.moveTo(0, 0); 499 mArrow.lineTo(mArrowWidth * mArrowScale, 0); 500 mArrow.lineTo((mArrowWidth * mArrowScale / 2), (mArrowHeight 501 * mArrowScale)); 502 mArrow.offset(x - inset, y); 503 mArrow.close(); 504 // draw a triangle 505 mArrowPaint.setColor(mColors[mColorIndex]); 506 c.rotate(startAngle + sweepAngle - ARROW_OFFSET_ANGLE, bounds.exactCenterX(), 507 bounds.exactCenterY()); 508 c.drawPath(mArrow, mArrowPaint); 509 } 510 } 511 512 /** 513 * Set the colors the progress spinner alternates between. 514 * 515 * @param colors Array of integers describing the colors. Must be non-<code>null</code>. 516 */ 517 public void setColors(@NonNull int[] colors) { 518 mColors = colors; 519 // if colors are reset, make sure to reset the color index as well 520 setColorIndex(0); 521 } 522 523 /** 524 * @param index Index into the color array of the color to display in 525 * the progress spinner. 526 */ 527 public void setColorIndex(int index) { 528 mColorIndex = index; 529 } 530 531 /** 532 * Proceed to the next available ring color. This will automatically 533 * wrap back to the beginning of colors. 534 */ 535 public void goToNextColor() { 536 mColorIndex = (mColorIndex + 1) % (mColors.length); 537 } 538 539 public void setColorFilter(ColorFilter filter) { 540 mPaint.setColorFilter(filter); 541 invalidateSelf(); 542 } 543 544 /** 545 * @param alpha Set the alpha of the progress spinner and associated arrowhead. 546 */ 547 public void setAlpha(int alpha) { 548 mAlpha = alpha; 549 } 550 551 /** 552 * @return Current alpha of the progress spinner and arrowhead. 553 */ 554 public int getAlpha() { 555 return mAlpha; 556 } 557 558 /** 559 * @param strokeWidth Set the stroke width of the progress spinner in pixels. 560 */ 561 public void setStrokeWidth(float strokeWidth) { 562 mStrokeWidth = strokeWidth; 563 mPaint.setStrokeWidth(strokeWidth); 564 invalidateSelf(); 565 } 566 567 @SuppressWarnings("unused") 568 public float getStrokeWidth() { 569 return mStrokeWidth; 570 } 571 572 @SuppressWarnings("unused") 573 public void setStartTrim(float startTrim) { 574 mStartTrim = startTrim; 575 invalidateSelf(); 576 } 577 578 @SuppressWarnings("unused") 579 public float getStartTrim() { 580 return mStartTrim; 581 } 582 583 public float getStartingStartTrim() { 584 return mStartingStartTrim; 585 } 586 587 public float getStartingEndTrim() { 588 return mStartingEndTrim; 589 } 590 591 @SuppressWarnings("unused") 592 public void setEndTrim(float endTrim) { 593 mEndTrim = endTrim; 594 invalidateSelf(); 595 } 596 597 @SuppressWarnings("unused") 598 public float getEndTrim() { 599 return mEndTrim; 600 } 601 602 @SuppressWarnings("unused") 603 public void setRotation(float rotation) { 604 mRotation = rotation; 605 invalidateSelf(); 606 } 607 608 @SuppressWarnings("unused") 609 public float getRotation() { 610 return mRotation; 611 } 612 613 public void setInsets(int width, int height) { 614 final float minEdge = (float) Math.min(width, height); 615 float insets; 616 if (mRingCenterRadius <= 0 || minEdge < 0) { 617 insets = (float) Math.ceil(mStrokeWidth / 2.0f); 618 } else { 619 insets = (float) (minEdge / 2.0f - mRingCenterRadius); 620 } 621 mStrokeInset = insets; 622 } 623 624 @SuppressWarnings("unused") 625 public float getInsets() { 626 return mStrokeInset; 627 } 628 629 /** 630 * @param centerRadius Inner radius in px of the circle the progress 631 * spinner arc traces. 632 */ 633 public void setCenterRadius(double centerRadius) { 634 mRingCenterRadius = centerRadius; 635 } 636 637 public double getCenterRadius() { 638 return mRingCenterRadius; 639 } 640 641 /** 642 * @param show Set to true to show the arrow head on the progress spinner. 643 */ 644 public void setShowArrow(boolean show) { 645 if (mShowArrow != show) { 646 mShowArrow = show; 647 invalidateSelf(); 648 } 649 } 650 651 /** 652 * @param scale Set the scale of the arrowhead for the spinner. 653 */ 654 public void setArrowScale(float scale) { 655 if (scale != mArrowScale) { 656 mArrowScale = scale; 657 invalidateSelf(); 658 } 659 } 660 661 /** 662 * @return The amount the progress spinner is currently rotated, between [0..1]. 663 */ 664 public float getStartingRotation() { 665 return mStartingRotation; 666 } 667 668 /** 669 * If the start / end trim are offset to begin with, store them so that 670 * animation starts from that offset. 671 */ 672 public void storeOriginals() { 673 mStartingStartTrim = mStartTrim; 674 mStartingEndTrim = mEndTrim; 675 mStartingRotation = mRotation; 676 } 677 678 /** 679 * Reset the progress spinner to default rotation, start and end angles. 680 */ 681 public void resetOriginals() { 682 mStartingStartTrim = 0; 683 mStartingEndTrim = 0; 684 mStartingRotation = 0; 685 setStartTrim(0); 686 setEndTrim(0); 687 setRotation(0); 688 } 689 690 private void invalidateSelf() { 691 mCallback.invalidateDrawable(null); 692 } 693 } 694 695 /** 696 * Squishes the interpolation curve into the second half of the animation. 697 */ 698 private static class EndCurveInterpolator extends AccelerateDecelerateInterpolator { 699 @Override 700 public float getInterpolation(float input) { 701 return super.getInterpolation(Math.max(0, (input - 0.5f) * 2.0f)); 702 } 703 } 704 705 /** 706 * Squishes the interpolation curve into the first half of the animation. 707 */ 708 private static class StartCurveInterpolator extends AccelerateDecelerateInterpolator { 709 @Override 710 public float getInterpolation(float input) { 711 return super.getInterpolation(Math.min(1, input * 2.0f)); 712 } 713 } 714} 715