package io.github.dreierf.materialintroscreen.widgets; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.ValueAnimator; import android.annotation.SuppressLint; 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.Parcel; import android.os.Parcelable; import android.util.AttributeSet; import android.view.View; import android.view.animation.Interpolator; import java.util.Arrays; import androidx.core.view.ViewCompat; import androidx.interpolator.view.animation.FastOutSlowInInterpolator; import org.asteroidos.sync.R; public class InkPageIndicator extends View implements CustomViewPager.OnPageChangeListener, View.OnAttachStateChangeListener { private static final int DEFAULT_DOT_SIZE = 8; private static final int DEFAULT_GAP = 12; private static final int DEFAULT_ANIM_DURATION = 400; private static final int DEFAULT_UNSELECTED_COLOUR = 0x80ffffff; private static final int DEFAULT_SELECTED_COLOUR = 0xffffffff; private static final float INVALID_FRACTION = -1f; private static final float MINIMAL_REVEAL = 0.00001f; private final Paint selectedPaint; private final Path unselectedDotPath; private final Path unselectedDotLeftPath; private final Path unselectedDotRightPath; private final RectF rectF; private final Interpolator interpolator; float endX1; float endY1; float endX2; float endY2; float controlX1; float controlY1; float controlX2; float controlY2; private int dotDiameter; private int gap; private long animDuration; private int unselectedColour; private float dotRadius; private float halfDotRadius; private long animHalfDuration; private float dotTopY; private float dotCenterY; private float dotBottomY; private SwipeableViewPager viewPager; private int pageCount; private int currentPage; private int previousPage; private float selectedDotX; private boolean selectedDotInPosition; private float[] dotCenterX; private float[] joiningFractions; private float retreatingJoinX1; private float retreatingJoinX2; private float[] dotRevealFractions; private boolean isAttachedToWindow; private boolean pageChanging; private Paint unselectedPaint; private Path combinedUnselectedPath; private ValueAnimator moveAnimation; private PendingRetreatAnimator retreatAnimation; private PendingRevealAnimator[] revealAnimations; public InkPageIndicator(Context context) { this(context, null, 0); } public InkPageIndicator(Context context, AttributeSet attrs) { this(context, attrs, 0); } public InkPageIndicator(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); final int density = (int) context.getResources().getDisplayMetrics().density; final TypedArray typedArray = getContext().obtainStyledAttributes( attrs, R.styleable.InkPageIndicator, defStyle, 0); dotDiameter = typedArray.getDimensionPixelSize(R.styleable.InkPageIndicator_dotDiameter, DEFAULT_DOT_SIZE * density); dotRadius = dotDiameter / 2; halfDotRadius = dotRadius / 2; gap = typedArray.getDimensionPixelSize(R.styleable.InkPageIndicator_dotGap, DEFAULT_GAP * density); animDuration = (long) typedArray.getInteger(R.styleable.InkPageIndicator_animationDuration, DEFAULT_ANIM_DURATION); animHalfDuration = animDuration / 2; unselectedColour = typedArray.getColor(R.styleable.InkPageIndicator_pageIndicatorColor, DEFAULT_UNSELECTED_COLOUR); int selectedColour = typedArray.getColor(R.styleable.InkPageIndicator_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); interpolator = new FastOutSlowInInterpolator(); combinedUnselectedPath = new Path(); unselectedDotPath = new Path(); unselectedDotLeftPath = new Path(); unselectedDotRightPath = new Path(); rectF = new RectF(); addOnAttachStateChangeListener(this); } private int getCount() { return viewPager.getAdapter().getCount(); } public void setViewPager(final SwipeableViewPager viewPager) { this.viewPager = viewPager; viewPager.addOnPageChangeListener(this); setPageCount(getCount()); viewPager.getAdapter().registerDataSetObserver(new DataSetObserver() { @Override public void onChanged() { setPageCount(getCount()); } }); setCurrentPageImmediate(); } @Override public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { if (isAttachedToWindow) { float fraction = positionOffset; int currentPosition = pageChanging ? previousPage : currentPage; int leftDotPosition = position; if (currentPosition != position) { fraction = 1f - positionOffset; if (fraction == 1f) { leftDotPosition = Math.min(currentPosition, position); } } setJoiningFraction(leftDotPosition, fraction); } } @Override public void onPageSelected(int position) { if (position < pageCount) { if (isAttachedToWindow) { setSelectedPage(position); } else { setCurrentPageImmediate(); } } } @Override public void onPageScrollStateChanged(int state) { } private void setPageCount(int pages) { if (pages > 0) { pageCount = pages; resetState(); requestLayout(); } } private void calculateDotPositions(int width) { int left = getPaddingLeft(); int top = getPaddingTop(); int right = width - 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); } dotTopY = top; dotCenterY = top + dotRadius; dotBottomY = top + dotDiameter; setCurrentPageImmediate(); } private void setCurrentPageImmediate() { if (viewPager != null) { currentPage = viewPager.getCurrentItem(); } else { currentPage = 0; } if (isDotAnimationStarted()) { selectedDotX = dotCenterX[currentPage]; } } private boolean isDotAnimationStarted() { return dotCenterX != null && dotCenterX.length > 0 && (moveAnimation == null || !moveAnimation.isStarted()); } private void resetState() { 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; } @SuppressLint("SwitchIntDef") @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: 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: width = desiredWidth; break; } setMeasuredDimension(width, height); calculateDotPositions(width); } private int getDesiredHeight() { return getPaddingTop() + dotDiameter + getPaddingBottom(); } private int getRequiredWidth() { return pageCount * dotDiameter + (pageCount - 1) * gap; } private int getDesiredWidth() { return getPaddingLeft() + getRequiredWidth() + getPaddingRight(); } @Override public void onViewAttachedToWindow(View view) { isAttachedToWindow = true; } @Override public void onViewDetachedFromWindow(View view) { isAttachedToWindow = false; } @Override protected void onDraw(Canvas canvas) { if (viewPager == null || pageCount == 0) return; drawUnselected(canvas); drawSelected(canvas); } private void drawUnselected(Canvas canvas) { combinedUnselectedPath.rewind(); for (int page = 0; page < pageCount; page++) { int nextXIndex; if (page == pageCount - 1) { nextXIndex = page; } else { nextXIndex = page + 1; } Path unselectedPath = getUnselectedPath(page, dotCenterX[page], dotCenterX[nextXIndex], page == pageCount - 1 ? INVALID_FRACTION : joiningFractions[page], dotRevealFractions[page]); unselectedPath.addPath(combinedUnselectedPath); combinedUnselectedPath.addPath(unselectedPath); } if (retreatingJoinX1 != INVALID_FRACTION) { Path retreatingJoinPath = getRetreatingJoinPath(); combinedUnselectedPath.addPath(retreatingJoinPath); } canvas.drawPath(combinedUnselectedPath, unselectedPaint); } private Path getUnselectedPath(int page, float centerX, float nextCenterX, float joiningFraction, float dotRevealFraction) { unselectedDotPath.rewind(); if (isDotNotJoining(page, joiningFraction, dotRevealFraction)) { unselectedDotPath.addCircle(dotCenterX[page], dotCenterY, dotRadius, Path.Direction.CW); } if (isDotJoining(joiningFraction)) { unselectedDotLeftPath.rewind(); unselectedDotLeftPath.moveTo(centerX, dotBottomY); rectF.set(centerX - dotRadius, dotTopY, centerX + dotRadius, dotBottomY); unselectedDotLeftPath.arcTo(rectF, 90, 180, true); 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); endX2 = centerX; endY2 = dotBottomY; controlX1 = endX1; controlY1 = endY1 + halfDotRadius; controlX2 = centerX + halfDotRadius; controlY2 = dotBottomY; unselectedDotLeftPath.cubicTo(controlX1, controlY1, controlX2, controlY2, endX2, endY2); unselectedDotPath.addPath(unselectedDotLeftPath); unselectedDotRightPath.rewind(); unselectedDotRightPath.moveTo(nextCenterX, dotBottomY); rectF.set(nextCenterX - dotRadius, dotTopY, nextCenterX + dotRadius, dotBottomY); unselectedDotRightPath.arcTo(rectF, 90, -180, true); 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); endX2 = nextCenterX; endY2 = dotBottomY; controlX1 = endX1; controlY1 = endY1 + halfDotRadius; controlX2 = endX2 - halfDotRadius; controlY2 = dotBottomY; unselectedDotRightPath.cubicTo(controlX1, controlY1, controlX2, controlY2, endX2, endY2); unselectedDotPath.addPath(unselectedDotRightPath); } if (joiningFraction > 0.5f && joiningFraction < 1f && retreatingJoinX1 == INVALID_FRACTION) { float adjustedFraction = (joiningFraction - 0.2f) * 1.25f; unselectedDotPath.moveTo(centerX, dotBottomY); rectF.set(centerX - dotRadius, dotTopY, centerX + dotRadius, dotBottomY); unselectedDotPath.arcTo(rectF, 90, 180, true); endX1 = centerX + dotRadius + (gap / 2); endY1 = dotCenterY - (adjustedFraction * dotRadius); controlX1 = endX1 - (adjustedFraction * dotRadius); controlY1 = dotTopY; controlX2 = endX1 - ((1 - adjustedFraction) * dotRadius); controlY2 = endY1; unselectedDotPath.cubicTo(controlX1, controlY1, controlX2, controlY2, endX1, endY1); endX2 = nextCenterX; endY2 = dotTopY; controlX1 = endX1 + ((1 - adjustedFraction) * dotRadius); controlY1 = endY1; controlX2 = endX1 + (adjustedFraction * dotRadius); controlY2 = dotTopY; unselectedDotPath.cubicTo(controlX1, controlY1, controlX2, controlY2, endX2, endY2); rectF.set(nextCenterX - dotRadius, dotTopY, nextCenterX + dotRadius, dotBottomY); unselectedDotPath.arcTo(rectF, 270, 180, true); endY1 = dotCenterY + (adjustedFraction * dotRadius); controlX1 = endX1 + (adjustedFraction * dotRadius); controlY1 = dotBottomY; controlX2 = endX1 + ((1 - adjustedFraction) * dotRadius); controlY2 = endY1; unselectedDotPath.cubicTo(controlX1, controlY1, controlX2, controlY2, endX1, endY1); endX2 = centerX; endY2 = dotBottomY; controlX1 = endX1 - ((1 - adjustedFraction) * dotRadius); controlY1 = endY1; controlX2 = endX1 - (adjustedFraction * dotRadius); controlY2 = endY2; unselectedDotPath.cubicTo(controlX1, controlY1, controlX2, controlY2, endX2, endY2); } if (joiningFraction == 1 && retreatingJoinX1 == INVALID_FRACTION) { rectF.set(centerX - dotRadius, dotTopY, nextCenterX + dotRadius, dotBottomY); unselectedDotPath.addRoundRect(rectF, dotRadius, dotRadius, Path.Direction.CW); } if (dotRevealFraction > MINIMAL_REVEAL) { unselectedDotPath.addCircle(centerX, dotCenterY, dotRevealFraction * dotRadius, Path.Direction.CW); } return unselectedDotPath; } private boolean isDotJoining(float joiningFraction) { return joiningFraction > 0f && joiningFraction <= 0.5f && retreatingJoinX1 == INVALID_FRACTION; } private boolean isDotNotJoining(int page, float joiningFraction, float dotRevealFraction) { return (joiningFraction == 0f || joiningFraction == INVALID_FRACTION) && dotRevealFraction == 0f && !(page == currentPage && selectedDotInPosition); } 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) { return; } pageChanging = true; previousPage = currentPage; currentPage = now; final int steps = Math.abs(now - previousPage); if (steps > 1) { if (now > previousPage) { for (int i = 0; i < steps; i++) { setJoiningFraction(previousPage + i, 1f); } } else { for (int i = -1; i > -steps; i--) { setJoiningFraction(previousPage + i, 1f); } } } moveAnimation = createMoveSelectedAnimator(dotCenterX[now], previousPage, now, steps); moveAnimation.start(); } private ValueAnimator createMoveSelectedAnimator( final float moveTo, int was, int now, int steps) { ValueAnimator moveSelected = ValueAnimator.ofFloat(selectedDotX, moveTo); retreatAnimation = new PendingRetreatAnimator(was, now, steps, now > was ? new RightwardStartPredicate(moveTo - ((moveTo - selectedDotX) * 0.25f)) : new LeftwardStartPredicate(moveTo + ((selectedDotX - moveTo) * 0.25f))); retreatAnimation.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { resetState(); pageChanging = false; } }); moveSelected.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator valueAnimator) { selectedDotX = (Float) valueAnimator.getAnimatedValue(); retreatAnimation.startIfNecessary(selectedDotX); ViewCompat.postInvalidateOnAnimation(InkPageIndicator.this); } }); moveSelected.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationStart(Animator animation) { selectedDotInPosition = false; } @Override public void onAnimationEnd(Animator animation) { selectedDotInPosition = true; } }); moveSelected.setStartDelay(selectedDotInPosition ? animDuration / 4L : 0L); moveSelected.setDuration(animDuration * 3L / 4L); moveSelected.setInterpolator(interpolator); return moveSelected; } private void setJoiningFraction(int leftDot, float fraction) { if (joiningFractions != null) { if (leftDot < joiningFractions.length) { joiningFractions[leftDot] = fraction; ViewCompat.postInvalidateOnAnimation(this); } } } public void clearJoiningFractions() { Arrays.fill(joiningFractions, 0f); ViewCompat.postInvalidateOnAnimation(this); } private void setDotRevealFraction(int dot, float fraction) { if (dot < dotRevealFractions.length) { dotRevealFractions[dot] = fraction; } ViewCompat.postInvalidateOnAnimation(this); } public void setPageIndicatorColor(int secondaryColor) { unselectedColour = secondaryColor; unselectedPaint = new Paint(Paint.ANTI_ALIAS_FLAG); unselectedPaint.setColor(unselectedColour); } @Override public void onRestoreInstanceState(Parcelable state) { SavedState savedState = (SavedState) state; super.onRestoreInstanceState(savedState.getSuperState()); currentPage = savedState.currentPage; requestLayout(); } @Override public Parcelable onSaveInstanceState() { Parcelable superState = super.onSaveInstanceState(); SavedState savedState = new SavedState(superState); savedState.currentPage = currentPage; return savedState; } static class SavedState extends BaseSavedState { public static final Creator<SavedState> CREATOR = new Creator<SavedState>() { @Override public SavedState createFromParcel(Parcel in) { return new SavedState(in); } @Override public SavedState[] newArray(int size) { return new SavedState[size]; } }; int currentPage; SavedState(Parcelable superState) { super(superState); } private SavedState(Parcel in) { super(in); currentPage = in.readInt(); } @Override public void writeToParcel(Parcel dest, int flags) { super.writeToParcel(dest, flags); dest.writeInt(currentPage); } } public abstract class PendingStartAnimator extends ValueAnimator { boolean hasStarted; StartPredicate predicate; PendingStartAnimator(StartPredicate predicate) { super(); this.predicate = predicate; hasStarted = false; } void startIfNecessary(float currentValue) { if (!hasStarted && predicate.shouldStart(currentValue)) { start(); hasStarted = true; } } } public class PendingRetreatAnimator extends PendingStartAnimator { 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) { setFloatValues(initialX1, finalX1); for (int i = 0; i < steps; i++) { revealAnimations[i] = new PendingRevealAnimator(was + i, new RightwardStartPredicate(dotCenterX[was + i])); dotsToHide[i] = was + i; } addUpdateListener(new AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator valueAnimator) { retreatingJoinX1 = (Float) valueAnimator.getAnimatedValue(); ViewCompat.postInvalidateOnAnimation(InkPageIndicator.this); for (PendingRevealAnimator pendingReveal : revealAnimations) { pendingReveal.startIfNecessary(retreatingJoinX1); } } }); } else { setFloatValues(initialX2, finalX2); for (int i = 0; i < steps; i++) { revealAnimations[i] = new PendingRevealAnimator(was - i, new LeftwardStartPredicate(dotCenterX[was - i])); dotsToHide[i] = was - i; } addUpdateListener(new AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator valueAnimator) { retreatingJoinX2 = (Float) valueAnimator.getAnimatedValue(); ViewCompat.postInvalidateOnAnimation(InkPageIndicator.this); for (PendingRevealAnimator pendingReveal : revealAnimations) { pendingReveal.startIfNecessary(retreatingJoinX2); } } }); } addListener(new AnimatorListenerAdapter() { @Override public void onAnimationStart(Animator animation) { clearJoiningFractions(); for (int dot : dotsToHide) { setDotRevealFraction(dot, MINIMAL_REVEAL); } retreatingJoinX1 = initialX1; retreatingJoinX2 = initialX2; ViewCompat.postInvalidateOnAnimation(InkPageIndicator.this); } @Override public void onAnimationEnd(Animator animation) { retreatingJoinX1 = INVALID_FRACTION; retreatingJoinX2 = INVALID_FRACTION; ViewCompat.postInvalidateOnAnimation(InkPageIndicator.this); } }); } } public class PendingRevealAnimator extends PendingStartAnimator { private int dot; PendingRevealAnimator(int dot, StartPredicate predicate) { super(predicate); setFloatValues(MINIMAL_REVEAL, 1f); this.dot = dot; setDuration(animHalfDuration); setInterpolator(interpolator); addUpdateListener(new AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator valueAnimator) { setDotRevealFraction(PendingRevealAnimator.this.dot, (Float) valueAnimator.getAnimatedValue()); } }); addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { setDotRevealFraction(PendingRevealAnimator.this.dot, 0f); ViewCompat.postInvalidateOnAnimation(InkPageIndicator.this); } }); } } public abstract class StartPredicate { float thresholdValue; StartPredicate(float thresholdValue) { this.thresholdValue = thresholdValue; } abstract boolean shouldStart(float currentValue); } public class RightwardStartPredicate extends StartPredicate { RightwardStartPredicate(float thresholdValue) { super(thresholdValue); } boolean shouldStart(float currentValue) { return currentValue > thresholdValue; } } public class LeftwardStartPredicate extends StartPredicate { LeftwardStartPredicate(float thresholdValue) { super(thresholdValue); } boolean shouldStart(float currentValue) { return currentValue < thresholdValue; } } }