1 /* 2 * Copyright (C) 2016 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 com.android.settings.widget; 18 19 import static android.view.animation.AnimationUtils.loadInterpolator; 20 21 import android.animation.Animator; 22 import android.animation.AnimatorListenerAdapter; 23 import android.animation.AnimatorSet; 24 import android.animation.ValueAnimator; 25 import android.content.Context; 26 import android.content.res.TypedArray; 27 import android.database.DataSetObserver; 28 import android.graphics.Canvas; 29 import android.graphics.Paint; 30 import android.graphics.Path; 31 import android.graphics.RectF; 32 import android.os.Build; 33 import android.support.v4.view.ViewPager; 34 import android.util.AttributeSet; 35 import android.view.View; 36 import android.view.animation.Interpolator; 37 import com.android.settings.R; 38 39 import java.util.Arrays; 40 41 /** 42 * Custom pager indicator for use with a {@code ViewPager}. 43 */ 44 public class DotsPageIndicator extends View implements ViewPager.OnPageChangeListener { 45 46 public static final String TAG = DotsPageIndicator.class.getSimpleName(); 47 48 // defaults 49 private static final int DEFAULT_DOT_SIZE = 8; // dp 50 private static final int DEFAULT_GAP = 12; // dp 51 private static final int DEFAULT_ANIM_DURATION = 400; // ms 52 private static final int DEFAULT_UNSELECTED_COLOUR = 0x80ffffff; // 50% white 53 private static final int DEFAULT_SELECTED_COLOUR = 0xffffffff; // 100% white 54 55 // constants 56 private static final float INVALID_FRACTION = -1f; 57 private static final float MINIMAL_REVEAL = 0.00001f; 58 59 // configurable attributes 60 private int dotDiameter; 61 private int gap; 62 private long animDuration; 63 private int unselectedColour; 64 private int selectedColour; 65 66 // derived from attributes 67 private float dotRadius; 68 private float halfDotRadius; 69 private long animHalfDuration; 70 private float dotTopY; 71 private float dotCenterY; 72 private float dotBottomY; 73 74 // ViewPager 75 private ViewPager viewPager; 76 private ViewPager.OnPageChangeListener pageChangeListener; 77 78 // state 79 private int pageCount; 80 private int currentPage; 81 private float selectedDotX; 82 private boolean selectedDotInPosition; 83 private float[] dotCenterX; 84 private float[] joiningFractions; 85 private float retreatingJoinX1; 86 private float retreatingJoinX2; 87 private float[] dotRevealFractions; 88 private boolean attachedState; 89 90 // drawing 91 private final Paint unselectedPaint; 92 private final Paint selectedPaint; 93 private final Path combinedUnselectedPath; 94 private final Path unselectedDotPath; 95 private final Path unselectedDotLeftPath; 96 private final Path unselectedDotRightPath; 97 private final RectF rectF; 98 99 // animation 100 private ValueAnimator moveAnimation; 101 private ValueAnimator[] joiningAnimations; 102 private AnimatorSet joiningAnimationSet; 103 private PendingRetreatAnimator retreatAnimation; 104 private PendingRevealAnimator[] revealAnimations; 105 private final Interpolator interpolator; 106 107 // working values for beziers 108 float endX1; 109 float endY1; 110 float endX2; 111 float endY2; 112 float controlX1; 113 float controlY1; 114 float controlX2; 115 float controlY2; 116 117 public DotsPageIndicator(Context context) { 118 this(context, null, 0); 119 } 120 121 public DotsPageIndicator(Context context, AttributeSet attrs) { 122 this(context, attrs, 0); 123 } 124 125 public DotsPageIndicator(Context context, AttributeSet attrs, int defStyle) { 126 super(context, attrs, defStyle); 127 final int scaledDensity = (int) context.getResources().getDisplayMetrics().scaledDensity; 128 129 // Load attributes 130 final TypedArray typedArray = getContext().obtainStyledAttributes( 131 attrs, R.styleable.DotsPageIndicator, defStyle, 0); 132 dotDiameter = typedArray.getDimensionPixelSize(R.styleable.DotsPageIndicator_dotDiameter, 133 DEFAULT_DOT_SIZE * scaledDensity); 134 dotRadius = dotDiameter / 2; 135 halfDotRadius = dotRadius / 2; 136 gap = typedArray.getDimensionPixelSize(R.styleable.DotsPageIndicator_dotGap, 137 DEFAULT_GAP * scaledDensity); 138 animDuration = (long) typedArray.getInteger(R.styleable.DotsPageIndicator_animationDuration, 139 DEFAULT_ANIM_DURATION); 140 animHalfDuration = animDuration / 2; 141 unselectedColour = typedArray.getColor(R.styleable.DotsPageIndicator_pageIndicatorColor, 142 DEFAULT_UNSELECTED_COLOUR); 143 selectedColour = typedArray.getColor(R.styleable.DotsPageIndicator_currentPageIndicatorColor, 144 DEFAULT_SELECTED_COLOUR); 145 typedArray.recycle(); 146 unselectedPaint = new Paint(Paint.ANTI_ALIAS_FLAG); 147 unselectedPaint.setColor(unselectedColour); 148 selectedPaint = new Paint(Paint.ANTI_ALIAS_FLAG); 149 selectedPaint.setColor(selectedColour); 150 151 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { 152 interpolator = loadInterpolator(context, android.R.interpolator.fast_out_slow_in); 153 } else { 154 interpolator = loadInterpolator(context, android.R.anim.accelerate_decelerate_interpolator); 155 } 156 157 // create paths & rect now reuse & rewind later 158 combinedUnselectedPath = new Path(); 159 unselectedDotPath = new Path(); 160 unselectedDotLeftPath = new Path(); 161 unselectedDotRightPath = new Path(); 162 rectF = new RectF(); 163 164 addOnAttachStateChangeListener(new OnAttachStateChangeListener() { 165 @Override 166 public void onViewAttachedToWindow(View v) { 167 attachedState = true; 168 } 169 @Override 170 public void onViewDetachedFromWindow(View v) { 171 attachedState = false; 172 } 173 }); 174 } 175 176 public void setViewPager(ViewPager viewPager) { 177 this.viewPager = viewPager; 178 viewPager.setOnPageChangeListener(this); 179 setPageCount(viewPager.getAdapter().getCount()); 180 viewPager.getAdapter().registerDataSetObserver(new DataSetObserver() { 181 @Override 182 public void onChanged() { 183 setPageCount(DotsPageIndicator.this.viewPager.getAdapter().getCount()); 184 } 185 }); 186 setCurrentPageImmediate(); 187 } 188 189 /*** 190 * As this class <b>must</b> act as the {@link ViewPager.OnPageChangeListener} for the ViewPager 191 * (as set by {@link #setViewPager(android.support.v4.view.ViewPager)}). Applications may set a 192 * listener here to be notified of the ViewPager events. 193 * 194 * @param onPageChangeListener 195 */ 196 public void setOnPageChangeListener(ViewPager.OnPageChangeListener onPageChangeListener) { 197 pageChangeListener = onPageChangeListener; 198 } 199 200 @Override 201 public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { 202 // nothing to do just forward onward to any registered listener 203 if (pageChangeListener != null) { 204 pageChangeListener.onPageScrolled(position, positionOffset, positionOffsetPixels); 205 } 206 } 207 208 @Override 209 public void onPageSelected(int position) { 210 if (attachedState) { 211 // this is the main event we're interested in! 212 setSelectedPage(position); 213 } else { 214 // when not attached, don't animate the move, just store immediately 215 setCurrentPageImmediate(); 216 } 217 218 // forward onward to any registered listener 219 if (pageChangeListener != null) { 220 pageChangeListener.onPageSelected(position); 221 } 222 } 223 224 @Override 225 public void onPageScrollStateChanged(int state) { 226 // nothing to do just forward onward to any registered listener 227 if (pageChangeListener != null) { 228 pageChangeListener.onPageScrollStateChanged(state); 229 } 230 } 231 232 private void setPageCount(int pages) { 233 pageCount = pages; 234 calculateDotPositions(); 235 resetState(); 236 } 237 238 private void calculateDotPositions() { 239 int left = getPaddingLeft(); 240 int top = getPaddingTop(); 241 int right = getWidth() - getPaddingRight(); 242 int requiredWidth = getRequiredWidth(); 243 float startLeft = left + ((right - left - requiredWidth) / 2) + dotRadius; 244 dotCenterX = new float[pageCount]; 245 for (int i = 0; i < pageCount; i++) { 246 dotCenterX[i] = startLeft + i * (dotDiameter + gap); 247 } 248 // todo just top aligning for now should make this smarter 249 dotTopY = top; 250 dotCenterY = top + dotRadius; 251 dotBottomY = top + dotDiameter; 252 setCurrentPageImmediate(); 253 } 254 255 private void setCurrentPageImmediate() { 256 if (viewPager != null) { 257 currentPage = viewPager.getCurrentItem(); 258 } else { 259 currentPage = 0; 260 } 261 262 if (pageCount > 0) { 263 selectedDotX = dotCenterX[currentPage]; 264 } 265 } 266 267 private void resetState() { 268 if (pageCount > 0) { 269 joiningFractions = new float[pageCount - 1]; 270 Arrays.fill(joiningFractions, 0f); 271 dotRevealFractions = new float[pageCount]; 272 Arrays.fill(dotRevealFractions, 0f); 273 retreatingJoinX1 = INVALID_FRACTION; 274 retreatingJoinX2 = INVALID_FRACTION; 275 selectedDotInPosition = true; 276 } 277 } 278 279 @Override 280 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 281 int desiredHeight = getDesiredHeight(); 282 int height; 283 switch (MeasureSpec.getMode(heightMeasureSpec)) { 284 case MeasureSpec.EXACTLY: 285 height = MeasureSpec.getSize(heightMeasureSpec); 286 break; 287 case MeasureSpec.AT_MOST: 288 height = Math.min(desiredHeight, MeasureSpec.getSize(heightMeasureSpec)); 289 break; 290 default: // MeasureSpec.UNSPECIFIED 291 height = desiredHeight; 292 break; 293 } 294 int desiredWidth = getDesiredWidth(); 295 int width; 296 switch (MeasureSpec.getMode(widthMeasureSpec)) { 297 case MeasureSpec.EXACTLY: 298 width = MeasureSpec.getSize(widthMeasureSpec); 299 break; 300 case MeasureSpec.AT_MOST: 301 width = Math.min(desiredWidth, MeasureSpec.getSize(widthMeasureSpec)); 302 break; 303 default: // MeasureSpec.UNSPECIFIED 304 width = desiredWidth; 305 break; 306 } 307 setMeasuredDimension(width, height); 308 calculateDotPositions(); 309 } 310 311 @Override 312 protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { 313 setMeasuredDimension(width, height); 314 calculateDotPositions(); 315 } 316 317 @Override 318 public void clearAnimation() { 319 super.clearAnimation(); 320 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { 321 cancelRunningAnimations(); 322 } 323 } 324 325 private int getDesiredHeight() { 326 return getPaddingTop() + dotDiameter + getPaddingBottom(); 327 } 328 329 private int getRequiredWidth() { 330 return pageCount * dotDiameter + (pageCount - 1) * gap; 331 } 332 333 private int getDesiredWidth() { 334 return getPaddingLeft() + getRequiredWidth() + getPaddingRight(); 335 } 336 337 @Override 338 protected void onDraw(Canvas canvas) { 339 if (viewPager == null || pageCount == 0) { 340 return; 341 } 342 drawUnselected(canvas); 343 drawSelected(canvas); 344 } 345 346 private void drawUnselected(Canvas canvas) { 347 combinedUnselectedPath.rewind(); 348 349 // draw any settled, revealing or joining dots 350 for (int page = 0; page < pageCount; page++) { 351 int nextXIndex = page == pageCount - 1 ? page : page + 1; 352 // todo Path.op should be supported in KitKat but causes the app to hang for Nexus 5. 353 // For now disabling for all pre-L devices. 354 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { 355 Path unselectedPath = getUnselectedPath(page, 356 dotCenterX[page], 357 dotCenterX[nextXIndex], 358 page == pageCount - 1 ? INVALID_FRACTION : joiningFractions[page], 359 dotRevealFractions[page]); 360 combinedUnselectedPath.op(unselectedPath, Path.Op.UNION); 361 } else { 362 canvas.drawCircle(dotCenterX[page], dotCenterY, dotRadius, unselectedPaint); 363 } 364 } 365 366 // draw any retreating joins 367 if (retreatingJoinX1 != INVALID_FRACTION) { 368 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { 369 combinedUnselectedPath.op(getRetreatingJoinPath(), Path.Op.UNION); 370 } 371 } 372 canvas.drawPath(combinedUnselectedPath, unselectedPaint); 373 } 374 375 /** 376 * Unselected dots can be in 6 states: 377 * 378 * #1 At rest 379 * #2 Joining neighbour, still separate 380 * #3 Joining neighbour, combined curved 381 * #4 Joining neighbour, combined straight 382 * #5 Join retreating 383 * #6 Dot re-showing / revealing 384 * 385 * It can also be in a combination of these states e.g. joining one neighbour while 386 * retreating from another. We therefore create a Path so that we can examine each 387 * dot pair separately and later take the union for these cases. 388 * 389 * This function returns a path for the given dot **and any action to it's right** e.g. joining 390 * or retreating from it's neighbour 391 * 392 * @param page 393 */ 394 private Path getUnselectedPath(int page, 395 float centerX, 396 float nextCenterX, 397 float joiningFraction, 398 float dotRevealFraction) { 399 unselectedDotPath.rewind(); 400 401 if ((joiningFraction == 0f || joiningFraction == INVALID_FRACTION) 402 && dotRevealFraction == 0f 403 && !(page == currentPage && selectedDotInPosition == true)) { 404 // case #1 At rest 405 unselectedDotPath.addCircle(dotCenterX[page], dotCenterY, dotRadius, Path.Direction.CW); 406 } 407 408 if (joiningFraction > 0f && joiningFraction < 0.5f && retreatingJoinX1 == INVALID_FRACTION) { 409 // case #2 Joining neighbour, still separate 410 // start with the left dot 411 unselectedDotLeftPath.rewind(); 412 413 // start at the bottom center 414 unselectedDotLeftPath.moveTo(centerX, dotBottomY); 415 416 // semi circle to the top center 417 rectF.set(centerX - dotRadius, dotTopY, centerX + dotRadius, dotBottomY); 418 unselectedDotLeftPath.arcTo(rectF, 90, 180, true); 419 420 // cubic to the right middle 421 endX1 = centerX + dotRadius + (joiningFraction * gap); 422 endY1 = dotCenterY; 423 controlX1 = centerX + halfDotRadius; 424 controlY1 = dotTopY; 425 controlX2 = endX1; 426 controlY2 = endY1 - halfDotRadius; 427 unselectedDotLeftPath.cubicTo(controlX1, controlY1, controlX2, controlY2, endX1, endY1); 428 429 // cubic back to the bottom center 430 endX2 = centerX; 431 endY2 = dotBottomY; 432 controlX1 = endX1; 433 controlY1 = endY1 + halfDotRadius; 434 controlX2 = centerX + halfDotRadius; 435 controlY2 = dotBottomY; 436 unselectedDotLeftPath.cubicTo(controlX1, controlY1, controlX2, controlY2, endX2, endY2); 437 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { 438 unselectedDotPath.op(unselectedDotLeftPath, Path.Op.UNION); 439 } 440 441 // now do the next dot to the right 442 unselectedDotRightPath.rewind(); 443 444 // start at the bottom center 445 unselectedDotRightPath.moveTo(nextCenterX, dotBottomY); 446 447 // semi circle to the top center 448 rectF.set(nextCenterX - dotRadius, dotTopY, nextCenterX + dotRadius, dotBottomY); 449 unselectedDotRightPath.arcTo(rectF, 90, -180, true); 450 451 // cubic to the left middle 452 endX1 = nextCenterX - dotRadius - (joiningFraction * gap); 453 endY1 = dotCenterY; 454 controlX1 = nextCenterX - halfDotRadius; 455 controlY1 = dotTopY; 456 controlX2 = endX1; 457 controlY2 = endY1 - halfDotRadius; 458 unselectedDotRightPath.cubicTo(controlX1, controlY1, controlX2, controlY2, endX1, endY1); 459 460 // cubic back to the bottom center 461 endX2 = nextCenterX; 462 endY2 = dotBottomY; 463 controlX1 = endX1; 464 controlY1 = endY1 + halfDotRadius; 465 controlX2 = endX2 - halfDotRadius; 466 controlY2 = dotBottomY; 467 unselectedDotRightPath.cubicTo(controlX1, controlY1, controlX2, controlY2, endX2, endY2); 468 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { 469 unselectedDotPath.op(unselectedDotRightPath, Path.Op.UNION); 470 } 471 } 472 473 if (joiningFraction > 0.5f && joiningFraction < 1f && retreatingJoinX1 == INVALID_FRACTION) { 474 // case #3 Joining neighbour, combined curved 475 // start in the bottom left 476 unselectedDotPath.moveTo(centerX, dotBottomY); 477 478 // semi-circle to the top left 479 rectF.set(centerX - dotRadius, dotTopY, centerX + dotRadius, dotBottomY); 480 unselectedDotPath.arcTo(rectF, 90, 180, true); 481 482 // bezier to the middle top of the join 483 endX1 = centerX + dotRadius + (gap / 2); 484 endY1 = dotCenterY - (joiningFraction * dotRadius); 485 controlX1 = endX1 - (joiningFraction * dotRadius); 486 controlY1 = dotTopY; 487 controlX2 = endX1 - ((1 - joiningFraction) * dotRadius); 488 controlY2 = endY1; 489 unselectedDotPath.cubicTo(controlX1, controlY1, controlX2, controlY2, endX1, endY1); 490 491 // bezier to the top right of the join 492 endX2 = nextCenterX; 493 endY2 = dotTopY; 494 controlX1 = endX1 + ((1 - joiningFraction) * dotRadius); 495 controlY1 = endY1; 496 controlX2 = endX1 + (joiningFraction * dotRadius); 497 controlY2 = dotTopY; 498 unselectedDotPath.cubicTo(controlX1, controlY1, controlX2, controlY2, endX2, endY2); 499 500 // semi-circle to the bottom right 501 rectF.set(nextCenterX - dotRadius, dotTopY, nextCenterX + dotRadius, dotBottomY); 502 unselectedDotPath.arcTo(rectF, 270, 180, true); 503 504 // bezier to the middle bottom of the join 505 // endX1 stays the same 506 endY1 = dotCenterY + (joiningFraction * dotRadius); 507 controlX1 = endX1 + (joiningFraction * dotRadius); 508 controlY1 = dotBottomY; 509 controlX2 = endX1 + ((1 - joiningFraction) * dotRadius); 510 controlY2 = endY1; 511 unselectedDotPath.cubicTo(controlX1, controlY1, controlX2, controlY2, endX1, endY1); 512 513 // bezier back to the start point in the bottom left 514 endX2 = centerX; 515 endY2 = dotBottomY; 516 controlX1 = endX1 - ((1 - joiningFraction) * dotRadius); 517 controlY1 = endY1; 518 controlX2 = endX1 - (joiningFraction * dotRadius); 519 controlY2 = endY2; 520 unselectedDotPath.cubicTo(controlX1, controlY1, controlX2, controlY2, endX2, endY2); 521 } 522 523 if (joiningFraction == 1 && retreatingJoinX1 == INVALID_FRACTION) { 524 // case #4 Joining neighbour, combined straight 525 // technically we could use case 3 for this situation as well 526 // but assume that this is an optimization rather than faffing around with beziers 527 // just to draw a rounded rect 528 rectF.set(centerX - dotRadius, dotTopY, nextCenterX + dotRadius, dotBottomY); 529 unselectedDotPath.addRoundRect(rectF, dotRadius, dotRadius, Path.Direction.CW); 530 } 531 532 // case #5 is handled by #getRetreatingJoinPath() 533 // this is done separately so that we can have a single retreating path spanning 534 // multiple dots and therefore animate it's movement smoothly 535 if (dotRevealFraction > MINIMAL_REVEAL) { 536 // case #6 previously hidden dot revealing 537 unselectedDotPath.addCircle(centerX, dotCenterY, dotRevealFraction * dotRadius, 538 Path.Direction.CW); 539 } 540 541 return unselectedDotPath; 542 } 543 544 private Path getRetreatingJoinPath() { 545 unselectedDotPath.rewind(); 546 rectF.set(retreatingJoinX1, dotTopY, retreatingJoinX2, dotBottomY); 547 unselectedDotPath.addRoundRect(rectF, dotRadius, dotRadius, Path.Direction.CW); 548 return unselectedDotPath; 549 } 550 551 private void drawSelected(Canvas canvas) { 552 canvas.drawCircle(selectedDotX, dotCenterY, dotRadius, selectedPaint); 553 } 554 555 private void setSelectedPage(int now) { 556 if (now == currentPage || pageCount == 0) { 557 return; 558 } 559 560 int was = currentPage; 561 currentPage = now; 562 563 // These animations are not supported in pre-JB versions. 564 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { 565 cancelRunningAnimations(); 566 567 // create the anim to move the selected dot this animator will kick off 568 // retreat animations when it has moved 75% of the way. 569 // The retreat animation in turn will kick of reveal anims when the 570 // retreat has passed any dots to be revealed 571 final int steps = Math.abs(now - was); 572 moveAnimation = createMoveSelectedAnimator(dotCenterX[now], was, now, steps); 573 574 // create animators for joining the dots. This runs independently of the above and relies 575 // on good timing. Like comedy. 576 // if joining multiple dots, each dot after the first is delayed by 1/8 of the duration 577 joiningAnimations = new ValueAnimator[steps]; 578 for (int i = 0; i < steps; i++) { 579 joiningAnimations[i] = createJoiningAnimator(now > was ? was + i : was - 1 - i, 580 i * (animDuration / 8L)); 581 } 582 moveAnimation.start(); 583 startJoiningAnimations(); 584 } else { 585 setCurrentPageImmediate(); 586 invalidate(); 587 } 588 } 589 590 private ValueAnimator createMoveSelectedAnimator(final float moveTo, int was, int now, 591 int steps) { 592 // create the actual move animator 593 ValueAnimator moveSelected = ValueAnimator.ofFloat(selectedDotX, moveTo); 594 595 // also set up a pending retreat anim this starts when the move is 75% complete 596 retreatAnimation = new PendingRetreatAnimator(was, now, steps, 597 now > was 598 ? new RightwardStartPredicate(moveTo - ((moveTo - selectedDotX) * 0.25f)) 599 : new LeftwardStartPredicate(moveTo + ((selectedDotX - moveTo) * 0.25f))); 600 601 moveSelected.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { 602 @Override 603 public void onAnimationUpdate(ValueAnimator valueAnimator) { 604 // todo avoid autoboxing 605 selectedDotX = (Float) valueAnimator.getAnimatedValue(); 606 retreatAnimation.startIfNecessary(selectedDotX); 607 postInvalidateOnAnimation(); 608 } 609 }); 610 611 moveSelected.addListener(new AnimatorListenerAdapter() { 612 @Override 613 public void onAnimationStart(Animator animation) { 614 // set a flag so that we continue to draw the unselected dot in the target position 615 // until the selected dot has finished moving into place 616 selectedDotInPosition = false; 617 } 618 @Override 619 public void onAnimationEnd(Animator animation) { 620 // set a flag when anim finishes so that we don't draw both selected & unselected 621 // page dots 622 selectedDotInPosition = true; 623 } 624 }); 625 626 // slightly delay the start to give the joins a chance to run 627 // unless dot isn't in position yet then don't delay! 628 moveSelected.setStartDelay(selectedDotInPosition ? animDuration / 4L : 0L); 629 moveSelected.setDuration(animDuration * 3L / 4L); 630 moveSelected.setInterpolator(interpolator); 631 return moveSelected; 632 } 633 634 private ValueAnimator createJoiningAnimator(final int leftJoiningDot, final long startDelay) { 635 // animate the joining fraction for the given dot 636 ValueAnimator joining = ValueAnimator.ofFloat(0f, 1.0f); 637 joining.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { 638 @Override 639 public void onAnimationUpdate(ValueAnimator valueAnimator) { 640 setJoiningFraction(leftJoiningDot, valueAnimator.getAnimatedFraction()); 641 } 642 }); 643 joining.setDuration(animHalfDuration); 644 joining.setStartDelay(startDelay); 645 joining.setInterpolator(interpolator); 646 return joining; 647 } 648 649 private void setJoiningFraction(int leftDot, float fraction) { 650 joiningFractions[leftDot] = fraction; 651 postInvalidateOnAnimation(); 652 } 653 654 private void clearJoiningFractions() { 655 Arrays.fill(joiningFractions, 0f); 656 postInvalidateOnAnimation(); 657 } 658 659 private void setDotRevealFraction(int dot, float fraction) { 660 dotRevealFractions[dot] = fraction; 661 postInvalidateOnAnimation(); 662 } 663 664 private void cancelRunningAnimations() { 665 cancelMoveAnimation(); 666 cancelJoiningAnimations(); 667 cancelRetreatAnimation(); 668 cancelRevealAnimations(); 669 resetState(); 670 } 671 672 private void cancelMoveAnimation() { 673 if (moveAnimation != null && moveAnimation.isRunning()) { 674 moveAnimation.cancel(); 675 } 676 } 677 678 private void startJoiningAnimations() { 679 joiningAnimationSet = new AnimatorSet(); 680 joiningAnimationSet.playTogether(joiningAnimations); 681 joiningAnimationSet.start(); 682 } 683 684 private void cancelJoiningAnimations() { 685 if (joiningAnimationSet != null && joiningAnimationSet.isRunning()) { 686 joiningAnimationSet.cancel(); 687 } 688 } 689 690 private void cancelRetreatAnimation() { 691 if (retreatAnimation != null && retreatAnimation.isRunning()) { 692 retreatAnimation.cancel(); 693 } 694 } 695 696 private void cancelRevealAnimations() { 697 if (revealAnimations != null) { 698 for (PendingRevealAnimator reveal : revealAnimations) { 699 reveal.cancel(); 700 } 701 } 702 } 703 704 int getUnselectedColour() { 705 return unselectedColour; 706 } 707 708 int getSelectedColour() { 709 return selectedColour; 710 } 711 712 float getDotCenterY() { 713 return dotCenterY; 714 } 715 716 float getDotCenterX(int page) { 717 return dotCenterX[page]; 718 } 719 720 float getSelectedDotX() { 721 return selectedDotX; 722 } 723 724 int getCurrentPage() { 725 return currentPage; 726 } 727 728 /** 729 * A {@link android.animation.ValueAnimator} that starts once a given predicate returns true. 730 */ 731 public abstract class PendingStartAnimator extends ValueAnimator { 732 733 protected boolean hasStarted; 734 protected StartPredicate predicate; 735 736 public PendingStartAnimator(StartPredicate predicate) { 737 super(); 738 this.predicate = predicate; 739 hasStarted = false; 740 } 741 742 public void startIfNecessary(float currentValue) { 743 if (!hasStarted && predicate.shouldStart(currentValue)) { 744 start(); 745 hasStarted = true; 746 } 747 } 748 } 749 750 /** 751 * An Animator that shows and then shrinks a retreating join between the previous and newly 752 * selected pages. This also sets up some pending dot reveals to be started when the retreat 753 * has passed the dot to be revealed. 754 */ 755 public class PendingRetreatAnimator extends PendingStartAnimator { 756 757 public PendingRetreatAnimator(int was, int now, int steps, StartPredicate predicate) { 758 super(predicate); 759 setDuration(animHalfDuration); 760 setInterpolator(interpolator); 761 762 // work out the start/end values of the retreating join from the direction we're 763 // travelling in. Also look at the current selected dot position, i.e. we're moving on 764 // before a prior anim has finished. 765 final float initialX1 = now > was ? Math.min(dotCenterX[was], selectedDotX) - dotRadius 766 : dotCenterX[now] - dotRadius; 767 final float finalX1 = now > was ? dotCenterX[now] - dotRadius 768 : dotCenterX[now] - dotRadius; 769 final float initialX2 = now > was ? dotCenterX[now] + dotRadius 770 : Math.max(dotCenterX[was], selectedDotX) + dotRadius; 771 final float finalX2 = now > was ? dotCenterX[now] + dotRadius 772 : dotCenterX[now] + dotRadius; 773 revealAnimations = new PendingRevealAnimator[steps]; 774 775 // hold on to the indexes of the dots that will be hidden by the retreat so that 776 // we can initialize their revealFraction's i.e. make sure they're hidden while the 777 // reveal animation runs 778 final int[] dotsToHide = new int[steps]; 779 if (initialX1 != finalX1) { // rightward retreat 780 setFloatValues(initialX1, finalX1); 781 // create the reveal animations that will run when the retreat passes them 782 for (int i = 0; i < steps; i++) { 783 revealAnimations[i] = new PendingRevealAnimator(was + i, 784 new RightwardStartPredicate(dotCenterX[was + i])); 785 dotsToHide[i] = was + i; 786 } 787 addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { 788 @Override 789 public void onAnimationUpdate(ValueAnimator valueAnimator) { 790 // todo avoid autoboxing 791 retreatingJoinX1 = (Float) valueAnimator.getAnimatedValue(); 792 postInvalidateOnAnimation(); 793 // start any reveal animations if we've passed them 794 for (PendingRevealAnimator pendingReveal : revealAnimations) { 795 pendingReveal.startIfNecessary(retreatingJoinX1); 796 } 797 } 798 }); 799 } else { // (initialX2 != finalX2) leftward retreat 800 setFloatValues(initialX2, finalX2); 801 // create the reveal animations that will run when the retreat passes them 802 for (int i = 0; i < steps; i++) { 803 revealAnimations[i] = new PendingRevealAnimator(was - i, 804 new LeftwardStartPredicate(dotCenterX[was - i])); 805 dotsToHide[i] = was - i; 806 } 807 addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { 808 @Override 809 public void onAnimationUpdate(ValueAnimator valueAnimator) { 810 // todo avoid autoboxing 811 retreatingJoinX2 = (Float) valueAnimator.getAnimatedValue(); 812 postInvalidateOnAnimation(); 813 // start any reveal animations if we've passed them 814 for (PendingRevealAnimator pendingReveal : revealAnimations) { 815 pendingReveal.startIfNecessary(retreatingJoinX2); 816 } 817 } 818 }); 819 } 820 821 addListener(new AnimatorListenerAdapter() { 822 @Override 823 public void onAnimationStart(Animator animation) { 824 cancelJoiningAnimations(); 825 clearJoiningFractions(); 826 // we need to set this so that the dots are hidden until the reveal anim runs 827 for (int dot : dotsToHide) { 828 setDotRevealFraction(dot, MINIMAL_REVEAL); 829 } 830 retreatingJoinX1 = initialX1; 831 retreatingJoinX2 = initialX2; 832 postInvalidateOnAnimation(); 833 } 834 @Override 835 public void onAnimationEnd(Animator animation) { 836 retreatingJoinX1 = INVALID_FRACTION; 837 retreatingJoinX2 = INVALID_FRACTION; 838 postInvalidateOnAnimation(); 839 } 840 }); 841 } 842 } 843 844 /** 845 * An Animator that animates a given dot's revealFraction i.e. scales it up 846 */ 847 public class PendingRevealAnimator extends PendingStartAnimator { 848 849 private final int dot; 850 851 public PendingRevealAnimator(int dot, StartPredicate predicate) { 852 super(predicate); 853 this.dot = dot; 854 setFloatValues(MINIMAL_REVEAL, 1f); 855 setDuration(animHalfDuration); 856 setInterpolator(interpolator); 857 858 addUpdateListener(new AnimatorUpdateListener() { 859 @Override 860 public void onAnimationUpdate(ValueAnimator valueAnimator) { 861 // todo avoid autoboxing 862 setDotRevealFraction(PendingRevealAnimator.this.dot, 863 (Float) valueAnimator.getAnimatedValue()); 864 } 865 }); 866 867 addListener(new AnimatorListenerAdapter() { 868 @Override 869 public void onAnimationEnd(Animator animation) { 870 setDotRevealFraction(PendingRevealAnimator.this.dot, 0f); 871 postInvalidateOnAnimation(); 872 } 873 }); 874 } 875 } 876 877 /** 878 * A predicate used to start an animation when a test passes 879 */ 880 public abstract class StartPredicate { 881 882 protected float thresholdValue; 883 884 public StartPredicate(float thresholdValue) { 885 this.thresholdValue = thresholdValue; 886 } 887 888 abstract boolean shouldStart(float currentValue); 889 } 890 891 /** 892 * A predicate used to start an animation when a given value is greater than a threshold 893 */ 894 public class RightwardStartPredicate extends StartPredicate { 895 896 public RightwardStartPredicate(float thresholdValue) { 897 super(thresholdValue); 898 } 899 900 boolean shouldStart(float currentValue) { 901 return currentValue > thresholdValue; 902 } 903 } 904 905 /** 906 * A predicate used to start an animation then a given value is less than a threshold 907 */ 908 public class LeftwardStartPredicate extends StartPredicate { 909 910 public LeftwardStartPredicate(float thresholdValue) { 911 super(thresholdValue); 912 } 913 914 boolean shouldStart(float currentValue) { 915 return currentValue < thresholdValue; 916 } 917 } 918 } 919