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