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