/* * Copyright (C) 2016 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License */ package com.android.settings.widget; import static android.view.animation.AnimationUtils.loadInterpolator; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; import android.animation.ValueAnimator; import android.content.Context; import android.content.res.TypedArray; import android.database.DataSetObserver; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Path; import android.graphics.RectF; import android.os.Build; import android.support.v4.view.ViewPager; import android.util.AttributeSet; import android.view.View; import android.view.animation.Interpolator; import com.android.settings.R; import java.util.Arrays; /** * Custom pager indicator for use with a {@code ViewPager}. */ public class DotsPageIndicator extends View implements ViewPager.OnPageChangeListener { public static final String TAG = DotsPageIndicator.class.getSimpleName(); // defaults private static final int DEFAULT_DOT_SIZE = 8; // dp private static final int DEFAULT_GAP = 12; // dp private static final int DEFAULT_ANIM_DURATION = 400; // ms private static final int DEFAULT_UNSELECTED_COLOUR = 0x80ffffff; // 50% white private static final int DEFAULT_SELECTED_COLOUR = 0xffffffff; // 100% white // constants private static final float INVALID_FRACTION = -1f; private static final float MINIMAL_REVEAL = 0.00001f; // configurable attributes private int dotDiameter; private int gap; private long animDuration; private int unselectedColour; private int selectedColour; // derived from attributes private float dotRadius; private float halfDotRadius; private long animHalfDuration; private float dotTopY; private float dotCenterY; private float dotBottomY; // ViewPager private ViewPager viewPager; private ViewPager.OnPageChangeListener pageChangeListener; // state private int pageCount; private int currentPage; private float selectedDotX; private boolean selectedDotInPosition; private float[] dotCenterX; private float[] joiningFractions; private float retreatingJoinX1; private float retreatingJoinX2; private float[] dotRevealFractions; private boolean attachedState; // drawing private final Paint unselectedPaint; private final Paint selectedPaint; private final Path combinedUnselectedPath; private final Path unselectedDotPath; private final Path unselectedDotLeftPath; private final Path unselectedDotRightPath; private final RectF rectF; // animation private ValueAnimator moveAnimation; private ValueAnimator[] joiningAnimations; private AnimatorSet joiningAnimationSet; private PendingRetreatAnimator retreatAnimation; private PendingRevealAnimator[] revealAnimations; private final Interpolator interpolator; // working values for beziers float endX1; float endY1; float endX2; float endY2; float controlX1; float controlY1; float controlX2; float controlY2; public DotsPageIndicator(Context context) { this(context, null, 0); } public DotsPageIndicator(Context context, AttributeSet attrs) { this(context, attrs, 0); } public DotsPageIndicator(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); final int scaledDensity = (int) context.getResources().getDisplayMetrics().scaledDensity; // Load attributes final TypedArray typedArray = getContext().obtainStyledAttributes( attrs, R.styleable.DotsPageIndicator, defStyle, 0); dotDiameter = typedArray.getDimensionPixelSize(R.styleable.DotsPageIndicator_dotDiameter, DEFAULT_DOT_SIZE * scaledDensity); dotRadius = dotDiameter / 2; halfDotRadius = dotRadius / 2; gap = typedArray.getDimensionPixelSize(R.styleable.DotsPageIndicator_dotGap, DEFAULT_GAP * scaledDensity); animDuration = (long) typedArray.getInteger(R.styleable.DotsPageIndicator_animationDuration, DEFAULT_ANIM_DURATION); animHalfDuration = animDuration / 2; unselectedColour = typedArray.getColor(R.styleable.DotsPageIndicator_pageIndicatorColor, DEFAULT_UNSELECTED_COLOUR); selectedColour = typedArray.getColor(R.styleable.DotsPageIndicator_currentPageIndicatorColor, DEFAULT_SELECTED_COLOUR); typedArray.recycle(); unselectedPaint = new Paint(Paint.ANTI_ALIAS_FLAG); unselectedPaint.setColor(unselectedColour); selectedPaint = new Paint(Paint.ANTI_ALIAS_FLAG); selectedPaint.setColor(selectedColour); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { interpolator = loadInterpolator(context, android.R.interpolator.fast_out_slow_in); } else { interpolator = loadInterpolator(context, android.R.anim.accelerate_decelerate_interpolator); } // create paths & rect now – reuse & rewind later combinedUnselectedPath = new Path(); unselectedDotPath = new Path(); unselectedDotLeftPath = new Path(); unselectedDotRightPath = new Path(); rectF = new RectF(); addOnAttachStateChangeListener(new OnAttachStateChangeListener() { @Override public void onViewAttachedToWindow(View v) { attachedState = true; } @Override public void onViewDetachedFromWindow(View v) { attachedState = false; } }); } public void setViewPager(ViewPager viewPager) { this.viewPager = viewPager; viewPager.setOnPageChangeListener(this); setPageCount(viewPager.getAdapter().getCount()); viewPager.getAdapter().registerDataSetObserver(new DataSetObserver() { @Override public void onChanged() { setPageCount(DotsPageIndicator.this.viewPager.getAdapter().getCount()); } }); setCurrentPageImmediate(); } /*** * As this class <b>must</b> act as the {@link ViewPager.OnPageChangeListener} for the ViewPager * (as set by {@link #setViewPager(android.support.v4.view.ViewPager)}). Applications may set a * listener here to be notified of the ViewPager events. * * @param onPageChangeListener */ public void setOnPageChangeListener(ViewPager.OnPageChangeListener onPageChangeListener) { pageChangeListener = onPageChangeListener; } @Override public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { // nothing to do – just forward onward to any registered listener if (pageChangeListener != null) { pageChangeListener.onPageScrolled(position, positionOffset, positionOffsetPixels); } } @Override public void onPageSelected(int position) { if (attachedState) { // this is the main event we're interested in! setSelectedPage(position); } else { // when not attached, don't animate the move, just store immediately setCurrentPageImmediate(); } // forward onward to any registered listener if (pageChangeListener != null) { pageChangeListener.onPageSelected(position); } } @Override public void onPageScrollStateChanged(int state) { // nothing to do – just forward onward to any registered listener if (pageChangeListener != null) { pageChangeListener.onPageScrollStateChanged(state); } } private void setPageCount(int pages) { pageCount = pages; calculateDotPositions(); resetState(); } private void calculateDotPositions() { int left = getPaddingLeft(); int top = getPaddingTop(); int right = getWidth() - getPaddingRight(); int requiredWidth = getRequiredWidth(); float startLeft = left + ((right - left - requiredWidth) / 2) + dotRadius; dotCenterX = new float[pageCount]; for (int i = 0; i < pageCount; i++) { dotCenterX[i] = startLeft + i * (dotDiameter + gap); } // todo just top aligning for now… should make this smarter dotTopY = top; dotCenterY = top + dotRadius; dotBottomY = top + dotDiameter; setCurrentPageImmediate(); } private void setCurrentPageImmediate() { if (viewPager != null) { currentPage = viewPager.getCurrentItem(); } else { currentPage = 0; } if (pageCount > 0) { selectedDotX = dotCenterX[currentPage]; } } private void resetState() { if (pageCount > 0) { joiningFractions = new float[pageCount - 1]; Arrays.fill(joiningFractions, 0f); dotRevealFractions = new float[pageCount]; Arrays.fill(dotRevealFractions, 0f); retreatingJoinX1 = INVALID_FRACTION; retreatingJoinX2 = INVALID_FRACTION; selectedDotInPosition = true; } } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int desiredHeight = getDesiredHeight(); int height; switch (MeasureSpec.getMode(heightMeasureSpec)) { case MeasureSpec.EXACTLY: height = MeasureSpec.getSize(heightMeasureSpec); break; case MeasureSpec.AT_MOST: height = Math.min(desiredHeight, MeasureSpec.getSize(heightMeasureSpec)); break; default: // MeasureSpec.UNSPECIFIED height = desiredHeight; break; } int desiredWidth = getDesiredWidth(); int width; switch (MeasureSpec.getMode(widthMeasureSpec)) { case MeasureSpec.EXACTLY: width = MeasureSpec.getSize(widthMeasureSpec); break; case MeasureSpec.AT_MOST: width = Math.min(desiredWidth, MeasureSpec.getSize(widthMeasureSpec)); break; default: // MeasureSpec.UNSPECIFIED width = desiredWidth; break; } setMeasuredDimension(width, height); calculateDotPositions(); } @Override protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { setMeasuredDimension(width, height); calculateDotPositions(); } @Override public void clearAnimation() { super.clearAnimation(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { cancelRunningAnimations(); } } private int getDesiredHeight() { return getPaddingTop() + dotDiameter + getPaddingBottom(); } private int getRequiredWidth() { return pageCount * dotDiameter + (pageCount - 1) * gap; } private int getDesiredWidth() { return getPaddingLeft() + getRequiredWidth() + getPaddingRight(); } @Override protected void onDraw(Canvas canvas) { if (viewPager == null || pageCount == 0) { return; } drawUnselected(canvas); drawSelected(canvas); } private void drawUnselected(Canvas canvas) { combinedUnselectedPath.rewind(); // draw any settled, revealing or joining dots for (int page = 0; page < pageCount; page++) { int nextXIndex = page == pageCount - 1 ? page : page + 1; // todo Path.op should be supported in KitKat but causes the app to hang for Nexus 5. // For now disabling for all pre-L devices. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { Path unselectedPath = getUnselectedPath(page, dotCenterX[page], dotCenterX[nextXIndex], page == pageCount - 1 ? INVALID_FRACTION : joiningFractions[page], dotRevealFractions[page]); combinedUnselectedPath.op(unselectedPath, Path.Op.UNION); } else { canvas.drawCircle(dotCenterX[page], dotCenterY, dotRadius, unselectedPaint); } } // draw any retreating joins if (retreatingJoinX1 != INVALID_FRACTION) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { combinedUnselectedPath.op(getRetreatingJoinPath(), Path.Op.UNION); } } canvas.drawPath(combinedUnselectedPath, unselectedPaint); } /** * Unselected dots can be in 6 states: * * #1 At rest * #2 Joining neighbour, still separate * #3 Joining neighbour, combined curved * #4 Joining neighbour, combined straight * #5 Join retreating * #6 Dot re-showing / revealing * * It can also be in a combination of these states e.g. joining one neighbour while * retreating from another. We therefore create a Path so that we can examine each * dot pair separately and later take the union for these cases. * * This function returns a path for the given dot **and any action to it's right** e.g. joining * or retreating from it's neighbour * * @param page */ private Path getUnselectedPath(int page, float centerX, float nextCenterX, float joiningFraction, float dotRevealFraction) { unselectedDotPath.rewind(); if ((joiningFraction == 0f || joiningFraction == INVALID_FRACTION) && dotRevealFraction == 0f && !(page == currentPage && selectedDotInPosition == true)) { // case #1 – At rest unselectedDotPath.addCircle(dotCenterX[page], dotCenterY, dotRadius, Path.Direction.CW); } if (joiningFraction > 0f && joiningFraction < 0.5f && retreatingJoinX1 == INVALID_FRACTION) { // case #2 – Joining neighbour, still separate // start with the left dot unselectedDotLeftPath.rewind(); // start at the bottom center unselectedDotLeftPath.moveTo(centerX, dotBottomY); // semi circle to the top center rectF.set(centerX - dotRadius, dotTopY, centerX + dotRadius, dotBottomY); unselectedDotLeftPath.arcTo(rectF, 90, 180, true); // cubic to the right middle endX1 = centerX + dotRadius + (joiningFraction * gap); endY1 = dotCenterY; controlX1 = centerX + halfDotRadius; controlY1 = dotTopY; controlX2 = endX1; controlY2 = endY1 - halfDotRadius; unselectedDotLeftPath.cubicTo(controlX1, controlY1, controlX2, controlY2, endX1, endY1); // cubic back to the bottom center endX2 = centerX; endY2 = dotBottomY; controlX1 = endX1; controlY1 = endY1 + halfDotRadius; controlX2 = centerX + halfDotRadius; controlY2 = dotBottomY; unselectedDotLeftPath.cubicTo(controlX1, controlY1, controlX2, controlY2, endX2, endY2); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { unselectedDotPath.op(unselectedDotLeftPath, Path.Op.UNION); } // now do the next dot to the right unselectedDotRightPath.rewind(); // start at the bottom center unselectedDotRightPath.moveTo(nextCenterX, dotBottomY); // semi circle to the top center rectF.set(nextCenterX - dotRadius, dotTopY, nextCenterX + dotRadius, dotBottomY); unselectedDotRightPath.arcTo(rectF, 90, -180, true); // cubic to the left middle endX1 = nextCenterX - dotRadius - (joiningFraction * gap); endY1 = dotCenterY; controlX1 = nextCenterX - halfDotRadius; controlY1 = dotTopY; controlX2 = endX1; controlY2 = endY1 - halfDotRadius; unselectedDotRightPath.cubicTo(controlX1, controlY1, controlX2, controlY2, endX1, endY1); // cubic back to the bottom center endX2 = nextCenterX; endY2 = dotBottomY; controlX1 = endX1; controlY1 = endY1 + halfDotRadius; controlX2 = endX2 - halfDotRadius; controlY2 = dotBottomY; unselectedDotRightPath.cubicTo(controlX1, controlY1, controlX2, controlY2, endX2, endY2); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { unselectedDotPath.op(unselectedDotRightPath, Path.Op.UNION); } } if (joiningFraction > 0.5f && joiningFraction < 1f && retreatingJoinX1 == INVALID_FRACTION) { // case #3 – Joining neighbour, combined curved // start in the bottom left unselectedDotPath.moveTo(centerX, dotBottomY); // semi-circle to the top left rectF.set(centerX - dotRadius, dotTopY, centerX + dotRadius, dotBottomY); unselectedDotPath.arcTo(rectF, 90, 180, true); // bezier to the middle top of the join endX1 = centerX + dotRadius + (gap / 2); endY1 = dotCenterY - (joiningFraction * dotRadius); controlX1 = endX1 - (joiningFraction * dotRadius); controlY1 = dotTopY; controlX2 = endX1 - ((1 - joiningFraction) * dotRadius); controlY2 = endY1; unselectedDotPath.cubicTo(controlX1, controlY1, controlX2, controlY2, endX1, endY1); // bezier to the top right of the join endX2 = nextCenterX; endY2 = dotTopY; controlX1 = endX1 + ((1 - joiningFraction) * dotRadius); controlY1 = endY1; controlX2 = endX1 + (joiningFraction * dotRadius); controlY2 = dotTopY; unselectedDotPath.cubicTo(controlX1, controlY1, controlX2, controlY2, endX2, endY2); // semi-circle to the bottom right rectF.set(nextCenterX - dotRadius, dotTopY, nextCenterX + dotRadius, dotBottomY); unselectedDotPath.arcTo(rectF, 270, 180, true); // bezier to the middle bottom of the join // endX1 stays the same endY1 = dotCenterY + (joiningFraction * dotRadius); controlX1 = endX1 + (joiningFraction * dotRadius); controlY1 = dotBottomY; controlX2 = endX1 + ((1 - joiningFraction) * dotRadius); controlY2 = endY1; unselectedDotPath.cubicTo(controlX1, controlY1, controlX2, controlY2, endX1, endY1); // bezier back to the start point in the bottom left endX2 = centerX; endY2 = dotBottomY; controlX1 = endX1 - ((1 - joiningFraction) * dotRadius); controlY1 = endY1; controlX2 = endX1 - (joiningFraction * dotRadius); controlY2 = endY2; unselectedDotPath.cubicTo(controlX1, controlY1, controlX2, controlY2, endX2, endY2); } if (joiningFraction == 1 && retreatingJoinX1 == INVALID_FRACTION) { // case #4 Joining neighbour, combined straight // technically we could use case 3 for this situation as well // but assume that this is an optimization rather than faffing around with beziers // just to draw a rounded rect rectF.set(centerX - dotRadius, dotTopY, nextCenterX + dotRadius, dotBottomY); unselectedDotPath.addRoundRect(rectF, dotRadius, dotRadius, Path.Direction.CW); } // case #5 is handled by #getRetreatingJoinPath() // this is done separately so that we can have a single retreating path spanning // multiple dots and therefore animate it's movement smoothly if (dotRevealFraction > MINIMAL_REVEAL) { // case #6 – previously hidden dot revealing unselectedDotPath.addCircle(centerX, dotCenterY, dotRevealFraction * dotRadius, Path.Direction.CW); } return unselectedDotPath; } private Path getRetreatingJoinPath() { unselectedDotPath.rewind(); rectF.set(retreatingJoinX1, dotTopY, retreatingJoinX2, dotBottomY); unselectedDotPath.addRoundRect(rectF, dotRadius, dotRadius, Path.Direction.CW); return unselectedDotPath; } private void drawSelected(Canvas canvas) { canvas.drawCircle(selectedDotX, dotCenterY, dotRadius, selectedPaint); } private void setSelectedPage(int now) { if (now == currentPage || pageCount == 0) { return; } int was = currentPage; currentPage = now; // These animations are not supported in pre-JB versions. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { cancelRunningAnimations(); // create the anim to move the selected dot – this animator will kick off // retreat animations when it has moved 75% of the way. // The retreat animation in turn will kick of reveal anims when the // retreat has passed any dots to be revealed final int steps = Math.abs(now - was); moveAnimation = createMoveSelectedAnimator(dotCenterX[now], was, now, steps); // create animators for joining the dots. This runs independently of the above and relies // on good timing. Like comedy. // if joining multiple dots, each dot after the first is delayed by 1/8 of the duration joiningAnimations = new ValueAnimator[steps]; for (int i = 0; i < steps; i++) { joiningAnimations[i] = createJoiningAnimator(now > was ? was + i : was - 1 - i, i * (animDuration / 8L)); } moveAnimation.start(); startJoiningAnimations(); } else { setCurrentPageImmediate(); invalidate(); } } private ValueAnimator createMoveSelectedAnimator(final float moveTo, int was, int now, int steps) { // create the actual move animator ValueAnimator moveSelected = ValueAnimator.ofFloat(selectedDotX, moveTo); // also set up a pending retreat anim – this starts when the move is 75% complete retreatAnimation = new PendingRetreatAnimator(was, now, steps, now > was ? new RightwardStartPredicate(moveTo - ((moveTo - selectedDotX) * 0.25f)) : new LeftwardStartPredicate(moveTo + ((selectedDotX - moveTo) * 0.25f))); moveSelected.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator valueAnimator) { // todo avoid autoboxing selectedDotX = (Float) valueAnimator.getAnimatedValue(); retreatAnimation.startIfNecessary(selectedDotX); postInvalidateOnAnimation(); } }); moveSelected.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationStart(Animator animation) { // set a flag so that we continue to draw the unselected dot in the target position // until the selected dot has finished moving into place selectedDotInPosition = false; } @Override public void onAnimationEnd(Animator animation) { // set a flag when anim finishes so that we don't draw both selected & unselected // page dots selectedDotInPosition = true; } }); // slightly delay the start to give the joins a chance to run // unless dot isn't in position yet – then don't delay! moveSelected.setStartDelay(selectedDotInPosition ? animDuration / 4L : 0L); moveSelected.setDuration(animDuration * 3L / 4L); moveSelected.setInterpolator(interpolator); return moveSelected; } private ValueAnimator createJoiningAnimator(final int leftJoiningDot, final long startDelay) { // animate the joining fraction for the given dot ValueAnimator joining = ValueAnimator.ofFloat(0f, 1.0f); joining.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator valueAnimator) { setJoiningFraction(leftJoiningDot, valueAnimator.getAnimatedFraction()); } }); joining.setDuration(animHalfDuration); joining.setStartDelay(startDelay); joining.setInterpolator(interpolator); return joining; } private void setJoiningFraction(int leftDot, float fraction) { joiningFractions[leftDot] = fraction; postInvalidateOnAnimation(); } private void clearJoiningFractions() { Arrays.fill(joiningFractions, 0f); postInvalidateOnAnimation(); } private void setDotRevealFraction(int dot, float fraction) { dotRevealFractions[dot] = fraction; postInvalidateOnAnimation(); } private void cancelRunningAnimations() { cancelMoveAnimation(); cancelJoiningAnimations(); cancelRetreatAnimation(); cancelRevealAnimations(); resetState(); } private void cancelMoveAnimation() { if (moveAnimation != null && moveAnimation.isRunning()) { moveAnimation.cancel(); } } private void startJoiningAnimations() { joiningAnimationSet = new AnimatorSet(); joiningAnimationSet.playTogether(joiningAnimations); joiningAnimationSet.start(); } private void cancelJoiningAnimations() { if (joiningAnimationSet != null && joiningAnimationSet.isRunning()) { joiningAnimationSet.cancel(); } } private void cancelRetreatAnimation() { if (retreatAnimation != null && retreatAnimation.isRunning()) { retreatAnimation.cancel(); } } private void cancelRevealAnimations() { if (revealAnimations != null) { for (PendingRevealAnimator reveal : revealAnimations) { reveal.cancel(); } } } int getUnselectedColour() { return unselectedColour; } int getSelectedColour() { return selectedColour; } float getDotCenterY() { return dotCenterY; } float getDotCenterX(int page) { return dotCenterX[page]; } float getSelectedDotX() { return selectedDotX; } int getCurrentPage() { return currentPage; } /** * A {@link android.animation.ValueAnimator} that starts once a given predicate returns true. */ public abstract class PendingStartAnimator extends ValueAnimator { protected boolean hasStarted; protected StartPredicate predicate; public PendingStartAnimator(StartPredicate predicate) { super(); this.predicate = predicate; hasStarted = false; } public void startIfNecessary(float currentValue) { if (!hasStarted && predicate.shouldStart(currentValue)) { start(); hasStarted = true; } } } /** * An Animator that shows and then shrinks a retreating join between the previous and newly * selected pages. This also sets up some pending dot reveals – to be started when the retreat * has passed the dot to be revealed. */ public class PendingRetreatAnimator extends PendingStartAnimator { public PendingRetreatAnimator(int was, int now, int steps, StartPredicate predicate) { super(predicate); setDuration(animHalfDuration); setInterpolator(interpolator); // work out the start/end values of the retreating join from the direction we're // travelling in. Also look at the current selected dot position, i.e. we're moving on // before a prior anim has finished. final float initialX1 = now > was ? Math.min(dotCenterX[was], selectedDotX) - dotRadius : dotCenterX[now] - dotRadius; final float finalX1 = now > was ? dotCenterX[now] - dotRadius : dotCenterX[now] - dotRadius; final float initialX2 = now > was ? dotCenterX[now] + dotRadius : Math.max(dotCenterX[was], selectedDotX) + dotRadius; final float finalX2 = now > was ? dotCenterX[now] + dotRadius : dotCenterX[now] + dotRadius; revealAnimations = new PendingRevealAnimator[steps]; // hold on to the indexes of the dots that will be hidden by the retreat so that // we can initialize their revealFraction's i.e. make sure they're hidden while the // reveal animation runs final int[] dotsToHide = new int[steps]; if (initialX1 != finalX1) { // rightward retreat setFloatValues(initialX1, finalX1); // create the reveal animations that will run when the retreat passes them for (int i = 0; i < steps; i++) { revealAnimations[i] = new PendingRevealAnimator(was + i, new RightwardStartPredicate(dotCenterX[was + i])); dotsToHide[i] = was + i; } addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator valueAnimator) { // todo avoid autoboxing retreatingJoinX1 = (Float) valueAnimator.getAnimatedValue(); postInvalidateOnAnimation(); // start any reveal animations if we've passed them for (PendingRevealAnimator pendingReveal : revealAnimations) { pendingReveal.startIfNecessary(retreatingJoinX1); } } }); } else { // (initialX2 != finalX2) leftward retreat setFloatValues(initialX2, finalX2); // create the reveal animations that will run when the retreat passes them for (int i = 0; i < steps; i++) { revealAnimations[i] = new PendingRevealAnimator(was - i, new LeftwardStartPredicate(dotCenterX[was - i])); dotsToHide[i] = was - i; } addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator valueAnimator) { // todo avoid autoboxing retreatingJoinX2 = (Float) valueAnimator.getAnimatedValue(); postInvalidateOnAnimation(); // start any reveal animations if we've passed them for (PendingRevealAnimator pendingReveal : revealAnimations) { pendingReveal.startIfNecessary(retreatingJoinX2); } } }); } addListener(new AnimatorListenerAdapter() { @Override public void onAnimationStart(Animator animation) { cancelJoiningAnimations(); clearJoiningFractions(); // we need to set this so that the dots are hidden until the reveal anim runs for (int dot : dotsToHide) { setDotRevealFraction(dot, MINIMAL_REVEAL); } retreatingJoinX1 = initialX1; retreatingJoinX2 = initialX2; postInvalidateOnAnimation(); } @Override public void onAnimationEnd(Animator animation) { retreatingJoinX1 = INVALID_FRACTION; retreatingJoinX2 = INVALID_FRACTION; postInvalidateOnAnimation(); } }); } } /** * An Animator that animates a given dot's revealFraction i.e. scales it up */ public class PendingRevealAnimator extends PendingStartAnimator { private final int dot; public PendingRevealAnimator(int dot, StartPredicate predicate) { super(predicate); this.dot = dot; setFloatValues(MINIMAL_REVEAL, 1f); setDuration(animHalfDuration); setInterpolator(interpolator); addUpdateListener(new AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator valueAnimator) { // todo avoid autoboxing setDotRevealFraction(PendingRevealAnimator.this.dot, (Float) valueAnimator.getAnimatedValue()); } }); addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { setDotRevealFraction(PendingRevealAnimator.this.dot, 0f); postInvalidateOnAnimation(); } }); } } /** * A predicate used to start an animation when a test passes */ public abstract class StartPredicate { protected float thresholdValue; public StartPredicate(float thresholdValue) { this.thresholdValue = thresholdValue; } abstract boolean shouldStart(float currentValue); } /** * A predicate used to start an animation when a given value is greater than a threshold */ public class RightwardStartPredicate extends StartPredicate { public RightwardStartPredicate(float thresholdValue) { super(thresholdValue); } boolean shouldStart(float currentValue) { return currentValue > thresholdValue; } } /** * A predicate used to start an animation then a given value is less than a threshold */ public class LeftwardStartPredicate extends StartPredicate { public LeftwardStartPredicate(float thresholdValue) { super(thresholdValue); } boolean shouldStart(float currentValue) { return currentValue < thresholdValue; } } }