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 
DotsPageIndicator(Context context)117     public DotsPageIndicator(Context context) {
118         this(context, null, 0);
119     }
120 
DotsPageIndicator(Context context, AttributeSet attrs)121     public DotsPageIndicator(Context context, AttributeSet attrs) {
122         this(context, attrs, 0);
123     }
124 
DotsPageIndicator(Context context, AttributeSet attrs, int defStyle)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 
setViewPager(ViewPager viewPager)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      */
setOnPageChangeListener(ViewPager.OnPageChangeListener onPageChangeListener)196     public void setOnPageChangeListener(ViewPager.OnPageChangeListener onPageChangeListener) {
197         pageChangeListener = onPageChangeListener;
198     }
199 
200     @Override
onPageScrolled(int position, float positionOffset, int positionOffsetPixels)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
onPageSelected(int position)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
onPageScrollStateChanged(int state)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 
setPageCount(int pages)232     private void setPageCount(int pages) {
233         pageCount = pages;
234         calculateDotPositions();
235         resetState();
236     }
237 
calculateDotPositions()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 
setCurrentPageImmediate()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 
resetState()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
onMeasure(int widthMeasureSpec, int heightMeasureSpec)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
onSizeChanged(int width, int height, int oldWidth, int oldHeight)312     protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) {
313         setMeasuredDimension(width, height);
314         calculateDotPositions();
315     }
316 
317     @Override
clearAnimation()318     public void clearAnimation() {
319         super.clearAnimation();
320         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
321             cancelRunningAnimations();
322         }
323     }
324 
getDesiredHeight()325     private int getDesiredHeight() {
326         return getPaddingTop() + dotDiameter + getPaddingBottom();
327     }
328 
getRequiredWidth()329     private int getRequiredWidth() {
330         return pageCount * dotDiameter + (pageCount - 1) * gap;
331     }
332 
getDesiredWidth()333     private int getDesiredWidth() {
334         return getPaddingLeft() + getRequiredWidth() + getPaddingRight();
335     }
336 
337     @Override
onDraw(Canvas canvas)338     protected void onDraw(Canvas canvas) {
339         if (viewPager == null || pageCount == 0) {
340             return;
341         }
342         drawUnselected(canvas);
343         drawSelected(canvas);
344     }
345 
drawUnselected(Canvas canvas)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      */
getUnselectedPath(int page, float centerX, float nextCenterX, float joiningFraction, float dotRevealFraction)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 
getRetreatingJoinPath()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 
drawSelected(Canvas canvas)551     private void drawSelected(Canvas canvas) {
552         canvas.drawCircle(selectedDotX, dotCenterY, dotRadius, selectedPaint);
553     }
554 
setSelectedPage(int now)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 
createMoveSelectedAnimator(final float moveTo, int was, int now, int steps)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 
createJoiningAnimator(final int leftJoiningDot, final long startDelay)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 
setJoiningFraction(int leftDot, float fraction)649     private void setJoiningFraction(int leftDot, float fraction) {
650         joiningFractions[leftDot] = fraction;
651         postInvalidateOnAnimation();
652     }
653 
clearJoiningFractions()654     private void clearJoiningFractions() {
655         Arrays.fill(joiningFractions, 0f);
656         postInvalidateOnAnimation();
657     }
658 
setDotRevealFraction(int dot, float fraction)659     private void setDotRevealFraction(int dot, float fraction) {
660         dotRevealFractions[dot] = fraction;
661         postInvalidateOnAnimation();
662     }
663 
cancelRunningAnimations()664     private void cancelRunningAnimations() {
665         cancelMoveAnimation();
666         cancelJoiningAnimations();
667         cancelRetreatAnimation();
668         cancelRevealAnimations();
669         resetState();
670     }
671 
cancelMoveAnimation()672     private void cancelMoveAnimation() {
673         if (moveAnimation != null && moveAnimation.isRunning()) {
674             moveAnimation.cancel();
675         }
676     }
677 
startJoiningAnimations()678     private void startJoiningAnimations() {
679         joiningAnimationSet = new AnimatorSet();
680         joiningAnimationSet.playTogether(joiningAnimations);
681         joiningAnimationSet.start();
682     }
683 
cancelJoiningAnimations()684     private void cancelJoiningAnimations() {
685         if (joiningAnimationSet != null && joiningAnimationSet.isRunning()) {
686             joiningAnimationSet.cancel();
687         }
688     }
689 
cancelRetreatAnimation()690     private void cancelRetreatAnimation() {
691         if (retreatAnimation != null && retreatAnimation.isRunning()) {
692             retreatAnimation.cancel();
693         }
694     }
695 
cancelRevealAnimations()696     private void cancelRevealAnimations() {
697         if (revealAnimations != null) {
698             for (PendingRevealAnimator reveal : revealAnimations) {
699                 reveal.cancel();
700             }
701         }
702     }
703 
getUnselectedColour()704     int getUnselectedColour() {
705         return unselectedColour;
706     }
707 
getSelectedColour()708     int getSelectedColour() {
709         return selectedColour;
710     }
711 
getDotCenterY()712     float getDotCenterY() {
713         return dotCenterY;
714     }
715 
getDotCenterX(int page)716     float getDotCenterX(int page) {
717         return dotCenterX[page];
718     }
719 
getSelectedDotX()720     float getSelectedDotX() {
721         return selectedDotX;
722     }
723 
getCurrentPage()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 
PendingStartAnimator(StartPredicate predicate)736         public PendingStartAnimator(StartPredicate predicate) {
737             super();
738             this.predicate = predicate;
739             hasStarted = false;
740         }
741 
startIfNecessary(float currentValue)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 
PendingRetreatAnimator(int was, int now, int steps, StartPredicate predicate)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 
PendingRevealAnimator(int dot, StartPredicate predicate)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 
StartPredicate(float thresholdValue)884         public StartPredicate(float thresholdValue) {
885             this.thresholdValue = thresholdValue;
886         }
887 
shouldStart(float currentValue)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 
RightwardStartPredicate(float thresholdValue)896         public RightwardStartPredicate(float thresholdValue) {
897             super(thresholdValue);
898         }
899 
shouldStart(float currentValue)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 
LeftwardStartPredicate(float thresholdValue)910         public LeftwardStartPredicate(float thresholdValue) {
911             super(thresholdValue);
912         }
913 
shouldStart(float currentValue)914         boolean shouldStart(float currentValue) {
915             return currentValue < thresholdValue;
916         }
917     }
918 }
919