package com.miris.ui.view; import android.animation.ValueAnimator; import android.content.Context; import android.content.res.Resources; import android.graphics.Color; import android.support.annotation.IdRes; import android.support.annotation.NonNull; import android.support.v4.view.MotionEventCompat; import android.support.v4.view.ViewCompat; import android.util.AttributeSet; import android.util.DisplayMetrics; import android.view.MotionEvent; import android.view.View; import android.view.ViewConfiguration; import android.view.ViewGroup; import android.view.ViewTreeObserver; import android.view.animation.AccelerateDecelerateInterpolator; import android.view.animation.Animation; import android.view.animation.DecelerateInterpolator; import android.view.animation.Transformation; import android.widget.AbsListView; import com.miris.ui.utils.DisplayUtil; public class WaveSwipeRefreshLayout extends ViewGroup implements ViewTreeObserver.OnPreDrawListener { private enum VERTICAL_DRAG_THRESHOLD { FIRST(0.1f), SECOND(0.16f + FIRST.val), THIRD(0.5f + FIRST.val); final float val; VERTICAL_DRAG_THRESHOLD(float val) { this.val = val; } } private enum STATE { REFRESHING, PENDING; } private enum EVENT_PHASE { WAITING, BEGINNING, APPEARING, EXPANDING, DROPPING; } private static final float DECELERATE_INTERPOLATION_FACTOR = 2f; private static final int INVALID_POINTER = -1; private static final float DRAGGING_WEIGHT = 0.5f; private static final float MAX_PROGRESS_ROTATION_RATE = 0.8f; private static final int SCALE_DOWN_DURATION = 200; private static final int ANIMATE_TO_TRIGGER_DURATION = 200; private static final int DEFAULT_CIRCLE_TARGET = 64; private View mTarget; private OnRefreshListener mListener; private STATE mState = STATE.PENDING; private EVENT_PHASE mEventPhase = EVENT_PHASE.WAITING; private final DecelerateInterpolator mDecelerateInterpolator; private ProgressAnimationImageView mCircleView; private WaveView mWaveView; private boolean mNotify; private boolean mIsManualRefresh = false; private float mFirstTouchDownPointY; private boolean mIsBeingDropped; private int mActivePointerId = INVALID_POINTER; public WaveSwipeRefreshLayout(Context context) { this(context, null); } public WaveSwipeRefreshLayout(Context context, AttributeSet attrs) { this(context, attrs, 0); } public WaveSwipeRefreshLayout(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); getViewTreeObserver().addOnPreDrawListener(this); setWillNotDraw(false); mDecelerateInterpolator = new DecelerateInterpolator(DECELERATE_INTERPOLATION_FACTOR); ViewCompat.setChildrenDrawingOrderEnabled(this, true); createProgressView(); createWaveView(); } private void createProgressView() { addView(mCircleView = new ProgressAnimationImageView(getContext())); } private void createWaveView() { mWaveView = new WaveView(getContext()); addView(mWaveView, 0); } @Override public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); ensureTarget(); mTarget.measure( makeMeasureSpecExactly(getMeasuredWidth() - (getPaddingLeft() + getPaddingRight())), makeMeasureSpecExactly(getMeasuredHeight() - (getPaddingTop() + getPaddingBottom()))); mWaveView.measure(widthMeasureSpec, heightMeasureSpec); mCircleView.measure(); } @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { if (getChildCount() == 0) { return; } ensureTarget(); final int thisWidth = getMeasuredWidth(); final int thisHeight = getMeasuredHeight(); final int childRight = thisWidth - getPaddingRight(); final int childBottom = thisHeight - getPaddingBottom(); mTarget.layout(getPaddingLeft(), getPaddingTop(), childRight, childBottom); final int circleWidth = mCircleView.getMeasuredWidth(); final int circleHeight = mCircleView.getMeasuredHeight(); mCircleView.layout((thisWidth - circleWidth) / 2, -circleHeight, (thisWidth + circleWidth) / 2, 0); mWaveView.layout(getPaddingLeft(), getPaddingTop(), childRight, childBottom); } @Override public boolean onPreDraw() { getViewTreeObserver().removeOnPreDrawListener(this); mWaveView.bringToFront(); mCircleView.bringToFront(); if (mIsManualRefresh) { mIsManualRefresh = false; mWaveView.manualRefresh(); reInitCircleView(); mCircleView.setBackgroundColor(Color.TRANSPARENT); mCircleView.setTranslationY( mWaveView.getCurrentCircleCenterY() + mCircleView.getHeight() / 2); animateOffsetToCorrectPosition(); } return false; } @Override public boolean onInterceptTouchEvent(@NonNull MotionEvent event) { ensureTarget(); if (!isEnabled() || canChildScrollUp() || isRefreshing()) { return false; } final int action = MotionEventCompat.getActionMasked(event); switch (action) { case MotionEvent.ACTION_DOWN: mActivePointerId = MotionEventCompat.getPointerId(event, 0); mFirstTouchDownPointY = getMotionEventY(event, mActivePointerId); break; case MotionEvent.ACTION_MOVE: if (mActivePointerId == INVALID_POINTER) { return false; } final float currentY = getMotionEventY(event, mActivePointerId); if (currentY == -1) { return false; } if (mFirstTouchDownPointY == -1) { mFirstTouchDownPointY = currentY; } final float yDiff = currentY - mFirstTouchDownPointY; if (yDiff > ViewConfiguration.get(getContext()).getScaledTouchSlop() && !isRefreshing()) { mCircleView.makeProgressTransparent(); return true; } break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: mActivePointerId = INVALID_POINTER; break; } return false; } @Override public void requestDisallowInterceptTouchEvent(boolean b) { } private void reInitCircleView() { if (mCircleView.getVisibility() != View.VISIBLE) { mCircleView.setVisibility(View.VISIBLE); } mCircleView.scaleWithKeepingAspectRatio(1f); mCircleView.makeProgressTransparent(); } private boolean onMoveTouchEvent(@NonNull MotionEvent event, int pointerIndex) { if (mIsBeingDropped) { return false; } final float y = MotionEventCompat.getY(event, pointerIndex); final float diffY = y - mFirstTouchDownPointY; final float overScrollTop = diffY * DRAGGING_WEIGHT; if (overScrollTop < 0) { mCircleView.showArrow(false); return false; } final DisplayMetrics metrics = getResources().getDisplayMetrics(); float originalDragPercent = overScrollTop / (DEFAULT_CIRCLE_TARGET * metrics.density); float dragPercent = Math.min(1f, originalDragPercent); float adjustedPercent = (float) Math.max(dragPercent - .4, 0) * 5 / 3; float tensionSlingshotPercent = (originalDragPercent > 3f) ? 2f : (originalDragPercent > 1f) ? originalDragPercent - 1f : 0; float tensionPercent = (4f - tensionSlingshotPercent) * tensionSlingshotPercent / 8f; mCircleView.showArrow(true); reInitCircleView(); if (originalDragPercent < 1f) { float strokeStart = adjustedPercent * .8f; mCircleView.setProgressStartEndTrim(0f, Math.min(MAX_PROGRESS_ROTATION_RATE, strokeStart)); mCircleView.setArrowScale(Math.min(1f, adjustedPercent)); } float rotation = (-0.25f + .4f * adjustedPercent + tensionPercent * 2) * .5f; mCircleView.setProgressRotation(rotation); mCircleView.setTranslationY(mWaveView.getCurrentCircleCenterY()); float seed = diffY / Math.min(getMeasuredWidth(), getMeasuredHeight()); float firstBounds = seed * (5f - 2 * seed) / 3.5f; float secondBounds = firstBounds - VERTICAL_DRAG_THRESHOLD.FIRST.val; float finalBounds = (firstBounds - VERTICAL_DRAG_THRESHOLD.SECOND.val) / 5; if (firstBounds < VERTICAL_DRAG_THRESHOLD.FIRST.val) { onBeginPhase(firstBounds); } else if (firstBounds < VERTICAL_DRAG_THRESHOLD.SECOND.val) { onAppearPhase(firstBounds, secondBounds); } else if (firstBounds < VERTICAL_DRAG_THRESHOLD.THIRD.val) { onExpandPhase(firstBounds, secondBounds, finalBounds); } else { onDropPhase(); } return !mIsBeingDropped; } private void onBeginPhase(float move1) { mWaveView.beginPhase(move1); setEventPhase(EVENT_PHASE.BEGINNING); } private void onAppearPhase(float move1, float move2) { mWaveView.appearPhase(move1, move2); setEventPhase(EVENT_PHASE.APPEARING); } private void onExpandPhase(float move1, float move2, float move3) { mWaveView.expandPhase(move1, move2, move3); setEventPhase(EVENT_PHASE.EXPANDING); } private void onDropPhase() { mWaveView.animationDropCircle(); ValueAnimator animator = ValueAnimator.ofFloat(0, 0); animator.setDuration(500); animator.setInterpolator(new AccelerateDecelerateInterpolator()); animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator valueAnimator) { mCircleView.setTranslationY( mWaveView.getCurrentCircleCenterY() + mCircleView.getHeight() / 2.f); } }); animator.start(); setRefreshing(true, true); mIsBeingDropped = true; setEventPhase(EVENT_PHASE.DROPPING); setEnabled(false); } @Override public boolean onTouchEvent(@NonNull MotionEvent event) { if (!isEnabled() || canChildScrollUp()) { return false; } mIsBeingDropped = mWaveView.isDisappearCircleAnimatorRunning(); final int action = MotionEventCompat.getActionMasked(event); switch (action) { case MotionEvent.ACTION_DOWN: break; case MotionEvent.ACTION_MOVE: final int pointerIndex = MotionEventCompat.findPointerIndex(event, mActivePointerId); return pointerIndex >= 0 && onMoveTouchEvent(event, pointerIndex); case MotionEvent.ACTION_UP: if (mIsBeingDropped) { mIsBeingDropped = false; return false; } final float diffY = event.getY() - mFirstTouchDownPointY; final float waveHeightThreshold = diffY * (5f - 2 * diffY / Math.min(getMeasuredWidth(), getMeasuredHeight())) / 1000f; mWaveView.startWaveAnimation(waveHeightThreshold); case MotionEvent.ACTION_CANCEL: if (mActivePointerId == INVALID_POINTER) { return false; } if (!isRefreshing()) { mCircleView.setProgressStartEndTrim(0f, 0f); mCircleView.showArrow(false); mCircleView.setVisibility(GONE); } mActivePointerId = INVALID_POINTER; return false; } return true; } private float getMotionEventY(@NonNull MotionEvent ev, int activePointerId) { final int index = MotionEventCompat.findPointerIndex(ev, activePointerId); if (index < 0) { return -1; } return MotionEventCompat.getY(ev, index); } private void animateOffsetToCorrectPosition() { mAnimateToCorrectPosition.reset(); mAnimateToCorrectPosition.setDuration(ANIMATE_TO_TRIGGER_DURATION); mAnimateToCorrectPosition.setInterpolator(mDecelerateInterpolator); mCircleView.setAnimationListener(mRefreshListener); mCircleView.clearAnimation(); mCircleView.startAnimation(mAnimateToCorrectPosition); } private final Animation mAnimateToCorrectPosition = new Animation() { @Override public void applyTransformation(float interpolatedTime, @NonNull Transformation t) { } }; public void setMaxDropHeight(int dropHeight) { mWaveView.setMaxDropHeight(dropHeight); } private Animation.AnimationListener mRefreshListener = new Animation.AnimationListener() { @Override public void onAnimationStart(Animation animation) { } @Override public void onAnimationRepeat(Animation animation) { } @Override public void onAnimationEnd(Animation animation) { if (isRefreshing()) { mCircleView.makeProgressTransparent(); mCircleView.startProgress(); if (mNotify) { if (mListener != null) { mListener.onRefresh(); } } } else { mCircleView.stopProgress(); mCircleView.setVisibility(View.GONE); mCircleView.makeProgressTransparent(); mWaveView.startDisappearCircleAnimation(); } } }; private void ensureTarget() { if (mTarget == null) { for (int i = 0; i < getChildCount(); i++) { View child = getChildAt(i); if (!child.equals(mCircleView) && !child.equals(mWaveView)) { mTarget = child; break; } } } if (mTarget == null) { throw new IllegalStateException("This view must have at least one AbsListView"); } } private void setRefreshing(boolean refreshing, final boolean notify) { if (isRefreshing() != refreshing) { mNotify = notify; ensureTarget(); setState(refreshing); if (isRefreshing()) { animateOffsetToCorrectPosition(); } else { startScaleDownAnimation(mRefreshListener); } } } private void setEventPhase(EVENT_PHASE eventPhase) { mEventPhase = eventPhase; } private void setState(STATE state) { mState = state; setEnabled(true); if (!isRefreshing()) { setEventPhase(EVENT_PHASE.WAITING); } } private void setState(boolean doRefresh) { setState((doRefresh) ? STATE.REFRESHING : STATE.PENDING); } private void startScaleDownAnimation(Animation.AnimationListener listener) { Animation scaleDownAnimation = new Animation() { @Override public void applyTransformation(float interpolatedTime, Transformation t) { mCircleView.scaleWithKeepingAspectRatio(1 - interpolatedTime); } }; scaleDownAnimation.setDuration(SCALE_DOWN_DURATION); mCircleView.setAnimationListener(listener); mCircleView.clearAnimation(); mCircleView.startAnimation(scaleDownAnimation); } public void setColorSchemeResources(@IdRes int... colorResIds) { mCircleView.setProgressColorSchemeColorsFromResource(colorResIds); } public void setColorSchemeColors(int... colors) { // FIXME Add @NonNull to the argument ensureTarget(); mCircleView.setProgressColorSchemeColors(colors); } public boolean isRefreshing() { return mState == STATE.REFRESHING; } private boolean isBeginning() { return mEventPhase == EVENT_PHASE.BEGINNING; } private boolean isExpanding() { return mEventPhase == EVENT_PHASE.EXPANDING; } private boolean isDropping() { return mEventPhase == EVENT_PHASE.DROPPING; } private boolean isAppearing() { return mEventPhase == EVENT_PHASE.APPEARING; } private boolean isWaiting() { return mEventPhase == EVENT_PHASE.WAITING; } public void setRefreshing(boolean refreshing) { if (refreshing && !isRefreshing()) { setState(true); mNotify = false; mIsManualRefresh = true; if (mWaveView.getCurrentCircleCenterY() == 0) { return; } mWaveView.manualRefresh(); reInitCircleView(); mCircleView.setTranslationY( mWaveView.getCurrentCircleCenterY() + mCircleView.getHeight() / 2); animateOffsetToCorrectPosition(); } else { setRefreshing(refreshing, false); } } public boolean canChildScrollUp() { if (mTarget == null) { return false; } if (android.os.Build.VERSION.SDK_INT < 14) { if (mTarget instanceof AbsListView) { final AbsListView absListView = (AbsListView) mTarget; return absListView.getChildCount() > 0 && (absListView.getFirstVisiblePosition() > 0 || absListView.getChildAt(0).getTop() < absListView.getPaddingTop()); } else { return mTarget.getScrollY() > 0; } } else { return ViewCompat.canScrollVertically(mTarget, -1); } } public void setShadowRadius(int radius) { radius = Math.max(0, radius); // set zero if negative mWaveView.setShadowRadius(radius); } public void setWaveColor(int color){ mWaveView.setWaveColor(color); } private static int makeMeasureSpecExactly(int length) { return MeasureSpec.makeMeasureSpec(length, MeasureSpec.EXACTLY); } public void setOnRefreshListener(OnRefreshListener listener) { mListener = listener; } public interface OnRefreshListener { void onRefresh(); } private class ProgressAnimationImageView extends AnimationImageView { private final MaterialProgressDrawable mProgress; public ProgressAnimationImageView(Context context) { super(context); mProgress = new MaterialProgressDrawable(context, WaveSwipeRefreshLayout.this); if (DisplayUtil.isOver600dp(getContext())) { // Make the progress be big mProgress.updateSizes(MaterialProgressDrawable.LARGE); } initialize(); } private void initialize() { setImageDrawable(null); mProgress.setBackgroundColor(Color.TRANSPARENT); setImageDrawable(mProgress); setVisibility(View.GONE); } public void measure() { final int circleDiameter = mProgress.getIntrinsicWidth(); measure(makeMeasureSpecExactly(circleDiameter), makeMeasureSpecExactly(circleDiameter)); } public void makeProgressTransparent() { mProgress.setAlpha(0xff); } public void showArrow(boolean show) { mProgress.showArrow(show); } public void setArrowScale(float scale) { mProgress.setArrowScale(scale); } public void setProgressAlpha(int alpha) { mProgress.setAlpha(alpha); } public void setProgressStartEndTrim(float startAngle, float endAngle) { mProgress.setStartEndTrim(startAngle, endAngle); } public void setProgressRotation(float rotation) { mProgress.setProgressRotation(rotation); } public void startProgress() { mProgress.start(); } public void stopProgress() { mProgress.stop(); } public void setProgressColorSchemeColors(@NonNull int... colors) { mProgress.setColorSchemeColors(colors); } public void setProgressColorSchemeColorsFromResource(@IdRes int... resources) { final Resources res = getResources(); final int[] colorRes = new int[resources.length]; for (int i = 0; i < resources.length; i++) { colorRes[i] = res.getColor(resources[i]); } setColorSchemeColors(colorRes); } public void scaleWithKeepingAspectRatio(float scale) { ViewCompat.setScaleX(this, scale); ViewCompat.setScaleY(this, scale); } } }