| /* |
| * Copyright (C) 2013 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 android.support.v4.widget; |
| |
| import android.content.Context; |
| import android.content.res.TypedArray; |
| import android.support.annotation.ColorInt; |
| import android.support.annotation.ColorRes; |
| import android.support.annotation.Nullable; |
| import android.support.annotation.VisibleForTesting; |
| import android.support.v4.content.ContextCompat; |
| import android.support.v4.view.NestedScrollingChild; |
| import android.support.v4.view.NestedScrollingChildHelper; |
| import android.support.v4.view.NestedScrollingParent; |
| import android.support.v4.view.NestedScrollingParentHelper; |
| import android.support.v4.view.ViewCompat; |
| import android.util.AttributeSet; |
| import android.util.DisplayMetrics; |
| import android.util.Log; |
| import android.view.MotionEvent; |
| import android.view.View; |
| import android.view.ViewConfiguration; |
| import android.view.ViewGroup; |
| import android.view.animation.Animation; |
| import android.view.animation.Animation.AnimationListener; |
| import android.view.animation.DecelerateInterpolator; |
| import android.view.animation.Transformation; |
| import android.widget.AbsListView; |
| import android.widget.ListView; |
| |
| /** |
| * The SwipeRefreshLayout should be used whenever the user can refresh the |
| * contents of a view via a vertical swipe gesture. The activity that |
| * instantiates this view should add an OnRefreshListener to be notified |
| * whenever the swipe to refresh gesture is completed. The SwipeRefreshLayout |
| * will notify the listener each and every time the gesture is completed again; |
| * the listener is responsible for correctly determining when to actually |
| * initiate a refresh of its content. If the listener determines there should |
| * not be a refresh, it must call setRefreshing(false) to cancel any visual |
| * indication of a refresh. If an activity wishes to show just the progress |
| * animation, it should call setRefreshing(true). To disable the gesture and |
| * progress animation, call setEnabled(false) on the view. |
| * <p> |
| * This layout should be made the parent of the view that will be refreshed as a |
| * result of the gesture and can only support one direct child. This view will |
| * also be made the target of the gesture and will be forced to match both the |
| * width and the height supplied in this layout. The SwipeRefreshLayout does not |
| * provide accessibility events; instead, a menu item must be provided to allow |
| * refresh of the content wherever this gesture is used. |
| * </p> |
| */ |
| public class SwipeRefreshLayout extends ViewGroup implements NestedScrollingParent, |
| NestedScrollingChild { |
| // Maps to ProgressBar.Large style |
| public static final int LARGE = CircularProgressDrawable.LARGE; |
| // Maps to ProgressBar default style |
| public static final int DEFAULT = CircularProgressDrawable.DEFAULT; |
| |
| @VisibleForTesting |
| static final int CIRCLE_DIAMETER = 40; |
| @VisibleForTesting |
| static final int CIRCLE_DIAMETER_LARGE = 56; |
| |
| private static final String LOG_TAG = SwipeRefreshLayout.class.getSimpleName(); |
| |
| private static final int MAX_ALPHA = 255; |
| private static final int STARTING_PROGRESS_ALPHA = (int) (.3f * MAX_ALPHA); |
| |
| private static final float DECELERATE_INTERPOLATION_FACTOR = 2f; |
| private static final int INVALID_POINTER = -1; |
| private static final float DRAG_RATE = .5f; |
| |
| // Max amount of circle that can be filled by progress during swipe gesture, |
| // where 1.0 is a full circle |
| private static final float MAX_PROGRESS_ANGLE = .8f; |
| |
| private static final int SCALE_DOWN_DURATION = 150; |
| |
| private static final int ALPHA_ANIMATION_DURATION = 300; |
| |
| private static final int ANIMATE_TO_TRIGGER_DURATION = 200; |
| |
| private static final int ANIMATE_TO_START_DURATION = 200; |
| |
| // Default background for the progress spinner |
| private static final int CIRCLE_BG_LIGHT = 0xFFFAFAFA; |
| // Default offset in dips from the top of the view to where the progress spinner should stop |
| private static final int DEFAULT_CIRCLE_TARGET = 64; |
| |
| private View mTarget; // the target of the gesture |
| OnRefreshListener mListener; |
| boolean mRefreshing = false; |
| private int mTouchSlop; |
| private float mTotalDragDistance = -1; |
| |
| // If nested scrolling is enabled, the total amount that needed to be |
| // consumed by this as the nested scrolling parent is used in place of the |
| // overscroll determined by MOVE events in the onTouch handler |
| private float mTotalUnconsumed; |
| private final NestedScrollingParentHelper mNestedScrollingParentHelper; |
| private final NestedScrollingChildHelper mNestedScrollingChildHelper; |
| private final int[] mParentScrollConsumed = new int[2]; |
| private final int[] mParentOffsetInWindow = new int[2]; |
| private boolean mNestedScrollInProgress; |
| |
| private int mMediumAnimationDuration; |
| int mCurrentTargetOffsetTop; |
| |
| private float mInitialMotionY; |
| private float mInitialDownY; |
| private boolean mIsBeingDragged; |
| private int mActivePointerId = INVALID_POINTER; |
| // Whether this item is scaled up rather than clipped |
| boolean mScale; |
| |
| // Target is returning to its start offset because it was cancelled or a |
| // refresh was triggered. |
| private boolean mReturningToStart; |
| private final DecelerateInterpolator mDecelerateInterpolator; |
| private static final int[] LAYOUT_ATTRS = new int[] { |
| android.R.attr.enabled |
| }; |
| |
| CircleImageView mCircleView; |
| private int mCircleViewIndex = -1; |
| |
| protected int mFrom; |
| |
| float mStartingScale; |
| |
| protected int mOriginalOffsetTop; |
| |
| int mSpinnerOffsetEnd; |
| |
| CircularProgressDrawable mProgress; |
| |
| private Animation mScaleAnimation; |
| |
| private Animation mScaleDownAnimation; |
| |
| private Animation mAlphaStartAnimation; |
| |
| private Animation mAlphaMaxAnimation; |
| |
| private Animation mScaleDownToStartAnimation; |
| |
| boolean mNotify; |
| |
| private int mCircleDiameter; |
| |
| // Whether the client has set a custom starting position; |
| boolean mUsingCustomStart; |
| |
| private OnChildScrollUpCallback mChildScrollUpCallback; |
| |
| 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 (mRefreshing) { |
| // Make sure the progress view is fully visible |
| mProgress.setAlpha(MAX_ALPHA); |
| mProgress.start(); |
| if (mNotify) { |
| if (mListener != null) { |
| mListener.onRefresh(); |
| } |
| } |
| mCurrentTargetOffsetTop = mCircleView.getTop(); |
| } else { |
| reset(); |
| } |
| } |
| }; |
| |
| void reset() { |
| mCircleView.clearAnimation(); |
| mProgress.stop(); |
| mCircleView.setVisibility(View.GONE); |
| setColorViewAlpha(MAX_ALPHA); |
| // Return the circle to its start position |
| if (mScale) { |
| setAnimationProgress(0 /* animation complete and view is hidden */); |
| } else { |
| setTargetOffsetTopAndBottom(mOriginalOffsetTop - mCurrentTargetOffsetTop); |
| } |
| mCurrentTargetOffsetTop = mCircleView.getTop(); |
| } |
| |
| @Override |
| public void setEnabled(boolean enabled) { |
| super.setEnabled(enabled); |
| if (!enabled) { |
| reset(); |
| } |
| } |
| |
| @Override |
| protected void onDetachedFromWindow() { |
| super.onDetachedFromWindow(); |
| reset(); |
| } |
| |
| private void setColorViewAlpha(int targetAlpha) { |
| mCircleView.getBackground().setAlpha(targetAlpha); |
| mProgress.setAlpha(targetAlpha); |
| } |
| |
| /** |
| * The refresh indicator starting and resting position is always positioned |
| * near the top of the refreshing content. This position is a consistent |
| * location, but can be adjusted in either direction based on whether or not |
| * there is a toolbar or actionbar present. |
| * <p> |
| * <strong>Note:</strong> Calling this will reset the position of the refresh indicator to |
| * <code>start</code>. |
| * </p> |
| * |
| * @param scale Set to true if there is no view at a higher z-order than where the progress |
| * spinner is set to appear. Setting it to true will cause indicator to be scaled |
| * up rather than clipped. |
| * @param start The offset in pixels from the top of this view at which the |
| * progress spinner should appear. |
| * @param end The offset in pixels from the top of this view at which the |
| * progress spinner should come to rest after a successful swipe |
| * gesture. |
| */ |
| public void setProgressViewOffset(boolean scale, int start, int end) { |
| mScale = scale; |
| mOriginalOffsetTop = start; |
| mSpinnerOffsetEnd = end; |
| mUsingCustomStart = true; |
| reset(); |
| mRefreshing = false; |
| } |
| |
| /** |
| * @return The offset in pixels from the top of this view at which the progress spinner should |
| * appear. |
| */ |
| public int getProgressViewStartOffset() { |
| return mOriginalOffsetTop; |
| } |
| |
| /** |
| * @return The offset in pixels from the top of this view at which the progress spinner should |
| * come to rest after a successful swipe gesture. |
| */ |
| public int getProgressViewEndOffset() { |
| return mSpinnerOffsetEnd; |
| } |
| |
| /** |
| * The refresh indicator resting position is always positioned near the top |
| * of the refreshing content. This position is a consistent location, but |
| * can be adjusted in either direction based on whether or not there is a |
| * toolbar or actionbar present. |
| * |
| * @param scale Set to true if there is no view at a higher z-order than where the progress |
| * spinner is set to appear. Setting it to true will cause indicator to be scaled |
| * up rather than clipped. |
| * @param end The offset in pixels from the top of this view at which the |
| * progress spinner should come to rest after a successful swipe |
| * gesture. |
| */ |
| public void setProgressViewEndTarget(boolean scale, int end) { |
| mSpinnerOffsetEnd = end; |
| mScale = scale; |
| mCircleView.invalidate(); |
| } |
| |
| /** |
| * One of DEFAULT, or LARGE. |
| */ |
| public void setSize(int size) { |
| if (size != CircularProgressDrawable.LARGE && size != CircularProgressDrawable.DEFAULT) { |
| return; |
| } |
| final DisplayMetrics metrics = getResources().getDisplayMetrics(); |
| if (size == CircularProgressDrawable.LARGE) { |
| mCircleDiameter = (int) (CIRCLE_DIAMETER_LARGE * metrics.density); |
| } else { |
| mCircleDiameter = (int) (CIRCLE_DIAMETER * metrics.density); |
| } |
| // force the bounds of the progress circle inside the circle view to |
| // update by setting it to null before updating its size and then |
| // re-setting it |
| mCircleView.setImageDrawable(null); |
| mProgress.setStyle(size); |
| mCircleView.setImageDrawable(mProgress); |
| } |
| |
| /** |
| * Simple constructor to use when creating a SwipeRefreshLayout from code. |
| * |
| * @param context |
| */ |
| public SwipeRefreshLayout(Context context) { |
| this(context, null); |
| } |
| |
| /** |
| * Constructor that is called when inflating SwipeRefreshLayout from XML. |
| * |
| * @param context |
| * @param attrs |
| */ |
| public SwipeRefreshLayout(Context context, AttributeSet attrs) { |
| super(context, attrs); |
| |
| mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); |
| |
| mMediumAnimationDuration = getResources().getInteger( |
| android.R.integer.config_mediumAnimTime); |
| |
| setWillNotDraw(false); |
| mDecelerateInterpolator = new DecelerateInterpolator(DECELERATE_INTERPOLATION_FACTOR); |
| |
| final DisplayMetrics metrics = getResources().getDisplayMetrics(); |
| mCircleDiameter = (int) (CIRCLE_DIAMETER * metrics.density); |
| |
| createProgressView(); |
| ViewCompat.setChildrenDrawingOrderEnabled(this, true); |
| // the absolute offset has to take into account that the circle starts at an offset |
| mSpinnerOffsetEnd = (int) (DEFAULT_CIRCLE_TARGET * metrics.density); |
| mTotalDragDistance = mSpinnerOffsetEnd; |
| mNestedScrollingParentHelper = new NestedScrollingParentHelper(this); |
| |
| mNestedScrollingChildHelper = new NestedScrollingChildHelper(this); |
| setNestedScrollingEnabled(true); |
| |
| mOriginalOffsetTop = mCurrentTargetOffsetTop = -mCircleDiameter; |
| moveToStart(1.0f); |
| |
| final TypedArray a = context.obtainStyledAttributes(attrs, LAYOUT_ATTRS); |
| setEnabled(a.getBoolean(0, true)); |
| a.recycle(); |
| } |
| |
| @Override |
| protected int getChildDrawingOrder(int childCount, int i) { |
| if (mCircleViewIndex < 0) { |
| return i; |
| } else if (i == childCount - 1) { |
| // Draw the selected child last |
| return mCircleViewIndex; |
| } else if (i >= mCircleViewIndex) { |
| // Move the children after the selected child earlier one |
| return i + 1; |
| } else { |
| // Keep the children before the selected child the same |
| return i; |
| } |
| } |
| |
| private void createProgressView() { |
| mCircleView = new CircleImageView(getContext(), CIRCLE_BG_LIGHT); |
| mProgress = new CircularProgressDrawable(getContext()); |
| mProgress.setStyle(CircularProgressDrawable.DEFAULT); |
| mCircleView.setImageDrawable(mProgress); |
| mCircleView.setVisibility(View.GONE); |
| addView(mCircleView); |
| } |
| |
| /** |
| * Set the listener to be notified when a refresh is triggered via the swipe |
| * gesture. |
| */ |
| public void setOnRefreshListener(OnRefreshListener listener) { |
| mListener = listener; |
| } |
| |
| /** |
| * Notify the widget that refresh state has changed. Do not call this when |
| * refresh is triggered by a swipe gesture. |
| * |
| * @param refreshing Whether or not the view should show refresh progress. |
| */ |
| public void setRefreshing(boolean refreshing) { |
| if (refreshing && mRefreshing != refreshing) { |
| // scale and show |
| mRefreshing = refreshing; |
| int endTarget = 0; |
| if (!mUsingCustomStart) { |
| endTarget = mSpinnerOffsetEnd + mOriginalOffsetTop; |
| } else { |
| endTarget = mSpinnerOffsetEnd; |
| } |
| setTargetOffsetTopAndBottom(endTarget - mCurrentTargetOffsetTop); |
| mNotify = false; |
| startScaleUpAnimation(mRefreshListener); |
| } else { |
| setRefreshing(refreshing, false /* notify */); |
| } |
| } |
| |
| private void startScaleUpAnimation(AnimationListener listener) { |
| mCircleView.setVisibility(View.VISIBLE); |
| if (android.os.Build.VERSION.SDK_INT >= 11) { |
| // Pre API 11, alpha is used in place of scale up to show the |
| // progress circle appearing. |
| // Don't adjust the alpha during appearance otherwise. |
| mProgress.setAlpha(MAX_ALPHA); |
| } |
| mScaleAnimation = new Animation() { |
| @Override |
| public void applyTransformation(float interpolatedTime, Transformation t) { |
| setAnimationProgress(interpolatedTime); |
| } |
| }; |
| mScaleAnimation.setDuration(mMediumAnimationDuration); |
| if (listener != null) { |
| mCircleView.setAnimationListener(listener); |
| } |
| mCircleView.clearAnimation(); |
| mCircleView.startAnimation(mScaleAnimation); |
| } |
| |
| /** |
| * Pre API 11, this does an alpha animation. |
| * @param progress |
| */ |
| void setAnimationProgress(float progress) { |
| mCircleView.setScaleX(progress); |
| mCircleView.setScaleY(progress); |
| } |
| |
| private void setRefreshing(boolean refreshing, final boolean notify) { |
| if (mRefreshing != refreshing) { |
| mNotify = notify; |
| ensureTarget(); |
| mRefreshing = refreshing; |
| if (mRefreshing) { |
| animateOffsetToCorrectPosition(mCurrentTargetOffsetTop, mRefreshListener); |
| } else { |
| startScaleDownAnimation(mRefreshListener); |
| } |
| } |
| } |
| |
| void startScaleDownAnimation(Animation.AnimationListener listener) { |
| mScaleDownAnimation = new Animation() { |
| @Override |
| public void applyTransformation(float interpolatedTime, Transformation t) { |
| setAnimationProgress(1 - interpolatedTime); |
| } |
| }; |
| mScaleDownAnimation.setDuration(SCALE_DOWN_DURATION); |
| mCircleView.setAnimationListener(listener); |
| mCircleView.clearAnimation(); |
| mCircleView.startAnimation(mScaleDownAnimation); |
| } |
| |
| private void startProgressAlphaStartAnimation() { |
| mAlphaStartAnimation = startAlphaAnimation(mProgress.getAlpha(), STARTING_PROGRESS_ALPHA); |
| } |
| |
| private void startProgressAlphaMaxAnimation() { |
| mAlphaMaxAnimation = startAlphaAnimation(mProgress.getAlpha(), MAX_ALPHA); |
| } |
| |
| private Animation startAlphaAnimation(final int startingAlpha, final int endingAlpha) { |
| Animation alpha = new Animation() { |
| @Override |
| public void applyTransformation(float interpolatedTime, Transformation t) { |
| mProgress.setAlpha( |
| (int) (startingAlpha + ((endingAlpha - startingAlpha) * interpolatedTime))); |
| } |
| }; |
| alpha.setDuration(ALPHA_ANIMATION_DURATION); |
| // Clear out the previous animation listeners. |
| mCircleView.setAnimationListener(null); |
| mCircleView.clearAnimation(); |
| mCircleView.startAnimation(alpha); |
| return alpha; |
| } |
| |
| /** |
| * @deprecated Use {@link #setProgressBackgroundColorSchemeResource(int)} |
| */ |
| @Deprecated |
| public void setProgressBackgroundColor(int colorRes) { |
| setProgressBackgroundColorSchemeResource(colorRes); |
| } |
| |
| /** |
| * Set the background color of the progress spinner disc. |
| * |
| * @param colorRes Resource id of the color. |
| */ |
| public void setProgressBackgroundColorSchemeResource(@ColorRes int colorRes) { |
| setProgressBackgroundColorSchemeColor(ContextCompat.getColor(getContext(), colorRes)); |
| } |
| |
| /** |
| * Set the background color of the progress spinner disc. |
| * |
| * @param color |
| */ |
| public void setProgressBackgroundColorSchemeColor(@ColorInt int color) { |
| mCircleView.setBackgroundColor(color); |
| } |
| |
| /** |
| * @deprecated Use {@link #setColorSchemeResources(int...)} |
| */ |
| @Deprecated |
| public void setColorScheme(@ColorRes int... colors) { |
| setColorSchemeResources(colors); |
| } |
| |
| /** |
| * Set the color resources used in the progress animation from color resources. |
| * The first color will also be the color of the bar that grows in response |
| * to a user swipe gesture. |
| * |
| * @param colorResIds |
| */ |
| public void setColorSchemeResources(@ColorRes int... colorResIds) { |
| final Context context = getContext(); |
| int[] colorRes = new int[colorResIds.length]; |
| for (int i = 0; i < colorResIds.length; i++) { |
| colorRes[i] = ContextCompat.getColor(context, colorResIds[i]); |
| } |
| setColorSchemeColors(colorRes); |
| } |
| |
| /** |
| * Set the colors used in the progress animation. The first |
| * color will also be the color of the bar that grows in response to a user |
| * swipe gesture. |
| * |
| * @param colors |
| */ |
| public void setColorSchemeColors(@ColorInt int... colors) { |
| ensureTarget(); |
| mProgress.setColorSchemeColors(colors); |
| } |
| |
| /** |
| * @return Whether the SwipeRefreshWidget is actively showing refresh |
| * progress. |
| */ |
| public boolean isRefreshing() { |
| return mRefreshing; |
| } |
| |
| private void ensureTarget() { |
| // Don't bother getting the parent height if the parent hasn't been laid |
| // out yet. |
| if (mTarget == null) { |
| for (int i = 0; i < getChildCount(); i++) { |
| View child = getChildAt(i); |
| if (!child.equals(mCircleView)) { |
| mTarget = child; |
| break; |
| } |
| } |
| } |
| } |
| |
| /** |
| * Set the distance to trigger a sync in dips |
| * |
| * @param distance |
| */ |
| public void setDistanceToTriggerSync(int distance) { |
| mTotalDragDistance = distance; |
| } |
| |
| @Override |
| protected void onLayout(boolean changed, int left, int top, int right, int bottom) { |
| final int width = getMeasuredWidth(); |
| final int height = getMeasuredHeight(); |
| if (getChildCount() == 0) { |
| return; |
| } |
| if (mTarget == null) { |
| ensureTarget(); |
| } |
| if (mTarget == null) { |
| return; |
| } |
| final View child = mTarget; |
| final int childLeft = getPaddingLeft(); |
| final int childTop = getPaddingTop(); |
| final int childWidth = width - getPaddingLeft() - getPaddingRight(); |
| final int childHeight = height - getPaddingTop() - getPaddingBottom(); |
| child.layout(childLeft, childTop, childLeft + childWidth, childTop + childHeight); |
| int circleWidth = mCircleView.getMeasuredWidth(); |
| int circleHeight = mCircleView.getMeasuredHeight(); |
| mCircleView.layout((width / 2 - circleWidth / 2), mCurrentTargetOffsetTop, |
| (width / 2 + circleWidth / 2), mCurrentTargetOffsetTop + circleHeight); |
| } |
| |
| @Override |
| public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { |
| super.onMeasure(widthMeasureSpec, heightMeasureSpec); |
| if (mTarget == null) { |
| ensureTarget(); |
| } |
| if (mTarget == null) { |
| return; |
| } |
| mTarget.measure(MeasureSpec.makeMeasureSpec( |
| getMeasuredWidth() - getPaddingLeft() - getPaddingRight(), |
| MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec( |
| getMeasuredHeight() - getPaddingTop() - getPaddingBottom(), MeasureSpec.EXACTLY)); |
| mCircleView.measure(MeasureSpec.makeMeasureSpec(mCircleDiameter, MeasureSpec.EXACTLY), |
| MeasureSpec.makeMeasureSpec(mCircleDiameter, MeasureSpec.EXACTLY)); |
| mCircleViewIndex = -1; |
| // Get the index of the circleview. |
| for (int index = 0; index < getChildCount(); index++) { |
| if (getChildAt(index) == mCircleView) { |
| mCircleViewIndex = index; |
| break; |
| } |
| } |
| } |
| |
| /** |
| * Get the diameter of the progress circle that is displayed as part of the |
| * swipe to refresh layout. |
| * |
| * @return Diameter in pixels of the progress circle view. |
| */ |
| public int getProgressCircleDiameter() { |
| return mCircleDiameter; |
| } |
| |
| /** |
| * @return Whether it is possible for the child view of this layout to |
| * scroll up. Override this if the child view is a custom view. |
| */ |
| public boolean canChildScrollUp() { |
| if (mChildScrollUpCallback != null) { |
| return mChildScrollUpCallback.canChildScrollUp(this, mTarget); |
| } |
| if (mTarget instanceof ListView) { |
| return ListViewCompat.canScrollList((ListView) mTarget, -1); |
| } |
| return mTarget.canScrollVertically(-1); |
| } |
| |
| /** |
| * Set a callback to override {@link SwipeRefreshLayout#canChildScrollUp()} method. Non-null |
| * callback will return the value provided by the callback and ignore all internal logic. |
| * @param callback Callback that should be called when canChildScrollUp() is called. |
| */ |
| public void setOnChildScrollUpCallback(@Nullable OnChildScrollUpCallback callback) { |
| mChildScrollUpCallback = callback; |
| } |
| |
| @Override |
| public boolean onInterceptTouchEvent(MotionEvent ev) { |
| ensureTarget(); |
| |
| final int action = ev.getActionMasked(); |
| int pointerIndex; |
| |
| if (mReturningToStart && action == MotionEvent.ACTION_DOWN) { |
| mReturningToStart = false; |
| } |
| |
| if (!isEnabled() || mReturningToStart || canChildScrollUp() |
| || mRefreshing || mNestedScrollInProgress) { |
| // Fail fast if we're not in a state where a swipe is possible |
| return false; |
| } |
| |
| switch (action) { |
| case MotionEvent.ACTION_DOWN: |
| setTargetOffsetTopAndBottom(mOriginalOffsetTop - mCircleView.getTop()); |
| mActivePointerId = ev.getPointerId(0); |
| mIsBeingDragged = false; |
| |
| pointerIndex = ev.findPointerIndex(mActivePointerId); |
| if (pointerIndex < 0) { |
| return false; |
| } |
| mInitialDownY = ev.getY(pointerIndex); |
| break; |
| |
| case MotionEvent.ACTION_MOVE: |
| if (mActivePointerId == INVALID_POINTER) { |
| Log.e(LOG_TAG, "Got ACTION_MOVE event but don't have an active pointer id."); |
| return false; |
| } |
| |
| pointerIndex = ev.findPointerIndex(mActivePointerId); |
| if (pointerIndex < 0) { |
| return false; |
| } |
| final float y = ev.getY(pointerIndex); |
| startDragging(y); |
| break; |
| |
| case MotionEvent.ACTION_POINTER_UP: |
| onSecondaryPointerUp(ev); |
| break; |
| |
| case MotionEvent.ACTION_UP: |
| case MotionEvent.ACTION_CANCEL: |
| mIsBeingDragged = false; |
| mActivePointerId = INVALID_POINTER; |
| break; |
| } |
| |
| return mIsBeingDragged; |
| } |
| |
| @Override |
| public void requestDisallowInterceptTouchEvent(boolean b) { |
| // if this is a List < L or another view that doesn't support nested |
| // scrolling, ignore this request so that the vertical scroll event |
| // isn't stolen |
| if ((android.os.Build.VERSION.SDK_INT < 21 && mTarget instanceof AbsListView) |
| || (mTarget != null && !ViewCompat.isNestedScrollingEnabled(mTarget))) { |
| // Nope. |
| } else { |
| super.requestDisallowInterceptTouchEvent(b); |
| } |
| } |
| |
| // NestedScrollingParent |
| |
| @Override |
| public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) { |
| return isEnabled() && !mReturningToStart && !mRefreshing |
| && (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0; |
| } |
| |
| @Override |
| public void onNestedScrollAccepted(View child, View target, int axes) { |
| // Reset the counter of how much leftover scroll needs to be consumed. |
| mNestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes); |
| // Dispatch up to the nested parent |
| startNestedScroll(axes & ViewCompat.SCROLL_AXIS_VERTICAL); |
| mTotalUnconsumed = 0; |
| mNestedScrollInProgress = true; |
| } |
| |
| @Override |
| public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) { |
| // If we are in the middle of consuming, a scroll, then we want to move the spinner back up |
| // before allowing the list to scroll |
| if (dy > 0 && mTotalUnconsumed > 0) { |
| if (dy > mTotalUnconsumed) { |
| consumed[1] = dy - (int) mTotalUnconsumed; |
| mTotalUnconsumed = 0; |
| } else { |
| mTotalUnconsumed -= dy; |
| consumed[1] = dy; |
| } |
| moveSpinner(mTotalUnconsumed); |
| } |
| |
| // If a client layout is using a custom start position for the circle |
| // view, they mean to hide it again before scrolling the child view |
| // If we get back to mTotalUnconsumed == 0 and there is more to go, hide |
| // the circle so it isn't exposed if its blocking content is moved |
| if (mUsingCustomStart && dy > 0 && mTotalUnconsumed == 0 |
| && Math.abs(dy - consumed[1]) > 0) { |
| mCircleView.setVisibility(View.GONE); |
| } |
| |
| // Now let our nested parent consume the leftovers |
| final int[] parentConsumed = mParentScrollConsumed; |
| if (dispatchNestedPreScroll(dx - consumed[0], dy - consumed[1], parentConsumed, null)) { |
| consumed[0] += parentConsumed[0]; |
| consumed[1] += parentConsumed[1]; |
| } |
| } |
| |
| @Override |
| public int getNestedScrollAxes() { |
| return mNestedScrollingParentHelper.getNestedScrollAxes(); |
| } |
| |
| @Override |
| public void onStopNestedScroll(View target) { |
| mNestedScrollingParentHelper.onStopNestedScroll(target); |
| mNestedScrollInProgress = false; |
| // Finish the spinner for nested scrolling if we ever consumed any |
| // unconsumed nested scroll |
| if (mTotalUnconsumed > 0) { |
| finishSpinner(mTotalUnconsumed); |
| mTotalUnconsumed = 0; |
| } |
| // Dispatch up our nested parent |
| stopNestedScroll(); |
| } |
| |
| @Override |
| public void onNestedScroll(final View target, final int dxConsumed, final int dyConsumed, |
| final int dxUnconsumed, final int dyUnconsumed) { |
| // Dispatch up to the nested parent first |
| dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, |
| mParentOffsetInWindow); |
| |
| // This is a bit of a hack. Nested scrolling works from the bottom up, and as we are |
| // sometimes between two nested scrolling views, we need a way to be able to know when any |
| // nested scrolling parent has stopped handling events. We do that by using the |
| // 'offset in window 'functionality to see if we have been moved from the event. |
| // This is a decent indication of whether we should take over the event stream or not. |
| final int dy = dyUnconsumed + mParentOffsetInWindow[1]; |
| if (dy < 0 && !canChildScrollUp()) { |
| mTotalUnconsumed += Math.abs(dy); |
| moveSpinner(mTotalUnconsumed); |
| } |
| } |
| |
| // NestedScrollingChild |
| |
| @Override |
| public void setNestedScrollingEnabled(boolean enabled) { |
| mNestedScrollingChildHelper.setNestedScrollingEnabled(enabled); |
| } |
| |
| @Override |
| public boolean isNestedScrollingEnabled() { |
| return mNestedScrollingChildHelper.isNestedScrollingEnabled(); |
| } |
| |
| @Override |
| public boolean startNestedScroll(int axes) { |
| return mNestedScrollingChildHelper.startNestedScroll(axes); |
| } |
| |
| @Override |
| public void stopNestedScroll() { |
| mNestedScrollingChildHelper.stopNestedScroll(); |
| } |
| |
| @Override |
| public boolean hasNestedScrollingParent() { |
| return mNestedScrollingChildHelper.hasNestedScrollingParent(); |
| } |
| |
| @Override |
| public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, |
| int dyUnconsumed, int[] offsetInWindow) { |
| return mNestedScrollingChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, |
| dxUnconsumed, dyUnconsumed, offsetInWindow); |
| } |
| |
| @Override |
| public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) { |
| return mNestedScrollingChildHelper.dispatchNestedPreScroll( |
| dx, dy, consumed, offsetInWindow); |
| } |
| |
| @Override |
| public boolean onNestedPreFling(View target, float velocityX, |
| float velocityY) { |
| return dispatchNestedPreFling(velocityX, velocityY); |
| } |
| |
| @Override |
| public boolean onNestedFling(View target, float velocityX, float velocityY, |
| boolean consumed) { |
| return dispatchNestedFling(velocityX, velocityY, consumed); |
| } |
| |
| @Override |
| public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) { |
| return mNestedScrollingChildHelper.dispatchNestedFling(velocityX, velocityY, consumed); |
| } |
| |
| @Override |
| public boolean dispatchNestedPreFling(float velocityX, float velocityY) { |
| return mNestedScrollingChildHelper.dispatchNestedPreFling(velocityX, velocityY); |
| } |
| |
| private boolean isAnimationRunning(Animation animation) { |
| return animation != null && animation.hasStarted() && !animation.hasEnded(); |
| } |
| |
| private void moveSpinner(float overscrollTop) { |
| mProgress.setArrowEnabled(true); |
| float originalDragPercent = overscrollTop / mTotalDragDistance; |
| |
| float dragPercent = Math.min(1f, Math.abs(originalDragPercent)); |
| float adjustedPercent = (float) Math.max(dragPercent - .4, 0) * 5 / 3; |
| float extraOS = Math.abs(overscrollTop) - mTotalDragDistance; |
| float slingshotDist = mUsingCustomStart ? mSpinnerOffsetEnd - mOriginalOffsetTop |
| : mSpinnerOffsetEnd; |
| float tensionSlingshotPercent = Math.max(0, Math.min(extraOS, slingshotDist * 2) |
| / slingshotDist); |
| float tensionPercent = (float) ((tensionSlingshotPercent / 4) - Math.pow( |
| (tensionSlingshotPercent / 4), 2)) * 2f; |
| float extraMove = (slingshotDist) * tensionPercent * 2; |
| |
| int targetY = mOriginalOffsetTop + (int) ((slingshotDist * dragPercent) + extraMove); |
| // where 1.0f is a full circle |
| if (mCircleView.getVisibility() != View.VISIBLE) { |
| mCircleView.setVisibility(View.VISIBLE); |
| } |
| if (!mScale) { |
| mCircleView.setScaleX(1f); |
| mCircleView.setScaleY(1f); |
| } |
| |
| if (mScale) { |
| setAnimationProgress(Math.min(1f, overscrollTop / mTotalDragDistance)); |
| } |
| if (overscrollTop < mTotalDragDistance) { |
| if (mProgress.getAlpha() > STARTING_PROGRESS_ALPHA |
| && !isAnimationRunning(mAlphaStartAnimation)) { |
| // Animate the alpha |
| startProgressAlphaStartAnimation(); |
| } |
| } else { |
| if (mProgress.getAlpha() < MAX_ALPHA && !isAnimationRunning(mAlphaMaxAnimation)) { |
| // Animate the alpha |
| startProgressAlphaMaxAnimation(); |
| } |
| } |
| float strokeStart = adjustedPercent * .8f; |
| mProgress.setStartEndTrim(0f, Math.min(MAX_PROGRESS_ANGLE, strokeStart)); |
| mProgress.setArrowScale(Math.min(1f, adjustedPercent)); |
| |
| float rotation = (-0.25f + .4f * adjustedPercent + tensionPercent * 2) * .5f; |
| mProgress.setProgressRotation(rotation); |
| setTargetOffsetTopAndBottom(targetY - mCurrentTargetOffsetTop); |
| } |
| |
| private void finishSpinner(float overscrollTop) { |
| if (overscrollTop > mTotalDragDistance) { |
| setRefreshing(true, true /* notify */); |
| } else { |
| // cancel refresh |
| mRefreshing = false; |
| mProgress.setStartEndTrim(0f, 0f); |
| Animation.AnimationListener listener = null; |
| if (!mScale) { |
| listener = new Animation.AnimationListener() { |
| |
| @Override |
| public void onAnimationStart(Animation animation) { |
| } |
| |
| @Override |
| public void onAnimationEnd(Animation animation) { |
| if (!mScale) { |
| startScaleDownAnimation(null); |
| } |
| } |
| |
| @Override |
| public void onAnimationRepeat(Animation animation) { |
| } |
| |
| }; |
| } |
| animateOffsetToStartPosition(mCurrentTargetOffsetTop, listener); |
| mProgress.setArrowEnabled(false); |
| } |
| } |
| |
| @Override |
| public boolean onTouchEvent(MotionEvent ev) { |
| final int action = ev.getActionMasked(); |
| int pointerIndex = -1; |
| |
| if (mReturningToStart && action == MotionEvent.ACTION_DOWN) { |
| mReturningToStart = false; |
| } |
| |
| if (!isEnabled() || mReturningToStart || canChildScrollUp() |
| || mRefreshing || mNestedScrollInProgress) { |
| // Fail fast if we're not in a state where a swipe is possible |
| return false; |
| } |
| |
| switch (action) { |
| case MotionEvent.ACTION_DOWN: |
| mActivePointerId = ev.getPointerId(0); |
| mIsBeingDragged = false; |
| break; |
| |
| case MotionEvent.ACTION_MOVE: { |
| pointerIndex = ev.findPointerIndex(mActivePointerId); |
| if (pointerIndex < 0) { |
| Log.e(LOG_TAG, "Got ACTION_MOVE event but have an invalid active pointer id."); |
| return false; |
| } |
| |
| final float y = ev.getY(pointerIndex); |
| startDragging(y); |
| |
| if (mIsBeingDragged) { |
| final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE; |
| if (overscrollTop > 0) { |
| moveSpinner(overscrollTop); |
| } else { |
| return false; |
| } |
| } |
| break; |
| } |
| case MotionEvent.ACTION_POINTER_DOWN: { |
| pointerIndex = ev.getActionIndex(); |
| if (pointerIndex < 0) { |
| Log.e(LOG_TAG, |
| "Got ACTION_POINTER_DOWN event but have an invalid action index."); |
| return false; |
| } |
| mActivePointerId = ev.getPointerId(pointerIndex); |
| break; |
| } |
| |
| case MotionEvent.ACTION_POINTER_UP: |
| onSecondaryPointerUp(ev); |
| break; |
| |
| case MotionEvent.ACTION_UP: { |
| pointerIndex = ev.findPointerIndex(mActivePointerId); |
| if (pointerIndex < 0) { |
| Log.e(LOG_TAG, "Got ACTION_UP event but don't have an active pointer id."); |
| return false; |
| } |
| |
| if (mIsBeingDragged) { |
| final float y = ev.getY(pointerIndex); |
| final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE; |
| mIsBeingDragged = false; |
| finishSpinner(overscrollTop); |
| } |
| mActivePointerId = INVALID_POINTER; |
| return false; |
| } |
| case MotionEvent.ACTION_CANCEL: |
| return false; |
| } |
| |
| return true; |
| } |
| |
| private void startDragging(float y) { |
| final float yDiff = y - mInitialDownY; |
| if (yDiff > mTouchSlop && !mIsBeingDragged) { |
| mInitialMotionY = mInitialDownY + mTouchSlop; |
| mIsBeingDragged = true; |
| mProgress.setAlpha(STARTING_PROGRESS_ALPHA); |
| } |
| } |
| |
| private void animateOffsetToCorrectPosition(int from, AnimationListener listener) { |
| mFrom = from; |
| mAnimateToCorrectPosition.reset(); |
| mAnimateToCorrectPosition.setDuration(ANIMATE_TO_TRIGGER_DURATION); |
| mAnimateToCorrectPosition.setInterpolator(mDecelerateInterpolator); |
| if (listener != null) { |
| mCircleView.setAnimationListener(listener); |
| } |
| mCircleView.clearAnimation(); |
| mCircleView.startAnimation(mAnimateToCorrectPosition); |
| } |
| |
| private void animateOffsetToStartPosition(int from, AnimationListener listener) { |
| if (mScale) { |
| // Scale the item back down |
| startScaleDownReturnToStartAnimation(from, listener); |
| } else { |
| mFrom = from; |
| mAnimateToStartPosition.reset(); |
| mAnimateToStartPosition.setDuration(ANIMATE_TO_START_DURATION); |
| mAnimateToStartPosition.setInterpolator(mDecelerateInterpolator); |
| if (listener != null) { |
| mCircleView.setAnimationListener(listener); |
| } |
| mCircleView.clearAnimation(); |
| mCircleView.startAnimation(mAnimateToStartPosition); |
| } |
| } |
| |
| private final Animation mAnimateToCorrectPosition = new Animation() { |
| @Override |
| public void applyTransformation(float interpolatedTime, Transformation t) { |
| int targetTop = 0; |
| int endTarget = 0; |
| if (!mUsingCustomStart) { |
| endTarget = mSpinnerOffsetEnd - Math.abs(mOriginalOffsetTop); |
| } else { |
| endTarget = mSpinnerOffsetEnd; |
| } |
| targetTop = (mFrom + (int) ((endTarget - mFrom) * interpolatedTime)); |
| int offset = targetTop - mCircleView.getTop(); |
| setTargetOffsetTopAndBottom(offset); |
| mProgress.setArrowScale(1 - interpolatedTime); |
| } |
| }; |
| |
| void moveToStart(float interpolatedTime) { |
| int targetTop = 0; |
| targetTop = (mFrom + (int) ((mOriginalOffsetTop - mFrom) * interpolatedTime)); |
| int offset = targetTop - mCircleView.getTop(); |
| setTargetOffsetTopAndBottom(offset); |
| } |
| |
| private final Animation mAnimateToStartPosition = new Animation() { |
| @Override |
| public void applyTransformation(float interpolatedTime, Transformation t) { |
| moveToStart(interpolatedTime); |
| } |
| }; |
| |
| private void startScaleDownReturnToStartAnimation(int from, |
| Animation.AnimationListener listener) { |
| mFrom = from; |
| mStartingScale = mCircleView.getScaleX(); |
| mScaleDownToStartAnimation = new Animation() { |
| @Override |
| public void applyTransformation(float interpolatedTime, Transformation t) { |
| float targetScale = (mStartingScale + (-mStartingScale * interpolatedTime)); |
| setAnimationProgress(targetScale); |
| moveToStart(interpolatedTime); |
| } |
| }; |
| mScaleDownToStartAnimation.setDuration(SCALE_DOWN_DURATION); |
| if (listener != null) { |
| mCircleView.setAnimationListener(listener); |
| } |
| mCircleView.clearAnimation(); |
| mCircleView.startAnimation(mScaleDownToStartAnimation); |
| } |
| |
| void setTargetOffsetTopAndBottom(int offset) { |
| mCircleView.bringToFront(); |
| ViewCompat.offsetTopAndBottom(mCircleView, offset); |
| mCurrentTargetOffsetTop = mCircleView.getTop(); |
| } |
| |
| private void onSecondaryPointerUp(MotionEvent ev) { |
| final int pointerIndex = ev.getActionIndex(); |
| final int pointerId = ev.getPointerId(pointerIndex); |
| if (pointerId == mActivePointerId) { |
| // This was our active pointer going up. Choose a new |
| // active pointer and adjust accordingly. |
| final int newPointerIndex = pointerIndex == 0 ? 1 : 0; |
| mActivePointerId = ev.getPointerId(newPointerIndex); |
| } |
| } |
| |
| /** |
| * Classes that wish to be notified when the swipe gesture correctly |
| * triggers a refresh should implement this interface. |
| */ |
| public interface OnRefreshListener { |
| /** |
| * Called when a swipe gesture triggers a refresh. |
| */ |
| void onRefresh(); |
| } |
| |
| /** |
| * Classes that wish to override {@link SwipeRefreshLayout#canChildScrollUp()} method |
| * behavior should implement this interface. |
| */ |
| public interface OnChildScrollUpCallback { |
| /** |
| * Callback that will be called when {@link SwipeRefreshLayout#canChildScrollUp()} method |
| * is called to allow the implementer to override its behavior. |
| * |
| * @param parent SwipeRefreshLayout that this callback is overriding. |
| * @param child The child view of SwipeRefreshLayout. |
| * |
| * @return Whether it is possible for the child view of parent layout to scroll up. |
| */ |
| boolean canChildScrollUp(SwipeRefreshLayout parent, @Nullable View child); |
| } |
| } |