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