1/*
2 * Copyright 2018 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package androidx.swiperefreshlayout.widget;
18
19import android.content.Context;
20import android.content.res.TypedArray;
21import android.util.AttributeSet;
22import android.util.DisplayMetrics;
23import android.util.Log;
24import android.view.MotionEvent;
25import android.view.View;
26import android.view.ViewConfiguration;
27import android.view.ViewGroup;
28import android.view.animation.Animation;
29import android.view.animation.Animation.AnimationListener;
30import android.view.animation.DecelerateInterpolator;
31import android.view.animation.Transformation;
32import android.widget.AbsListView;
33import android.widget.ListView;
34
35import androidx.annotation.ColorInt;
36import androidx.annotation.ColorRes;
37import androidx.annotation.NonNull;
38import androidx.annotation.Nullable;
39import androidx.annotation.Px;
40import androidx.annotation.VisibleForTesting;
41import androidx.core.content.ContextCompat;
42import androidx.core.view.NestedScrollingChild;
43import androidx.core.view.NestedScrollingChildHelper;
44import androidx.core.view.NestedScrollingParent;
45import androidx.core.view.NestedScrollingParentHelper;
46import androidx.core.view.ViewCompat;
47import androidx.core.widget.ListViewCompat;
48
49/**
50 * The SwipeRefreshLayout should be used whenever the user can refresh the
51 * contents of a view via a vertical swipe gesture. The activity that
52 * instantiates this view should add an OnRefreshListener to be notified
53 * whenever the swipe to refresh gesture is completed. The SwipeRefreshLayout
54 * will notify the listener each and every time the gesture is completed again;
55 * the listener is responsible for correctly determining when to actually
56 * initiate a refresh of its content. If the listener determines there should
57 * not be a refresh, it must call setRefreshing(false) to cancel any visual
58 * indication of a refresh. If an activity wishes to show just the progress
59 * animation, it should call setRefreshing(true). To disable the gesture and
60 * progress animation, call setEnabled(false) on the view.
61 * <p>
62 * This layout should be made the parent of the view that will be refreshed as a
63 * result of the gesture and can only support one direct child. This view will
64 * also be made the target of the gesture and will be forced to match both the
65 * width and the height supplied in this layout. The SwipeRefreshLayout does not
66 * provide accessibility events; instead, a menu item must be provided to allow
67 * refresh of the content wherever this gesture is used.
68 * </p>
69 */
70public class SwipeRefreshLayout extends ViewGroup implements NestedScrollingParent,
71        NestedScrollingChild {
72    // Maps to ProgressBar.Large style
73    public static final int LARGE = CircularProgressDrawable.LARGE;
74    // Maps to ProgressBar default style
75    public static final int DEFAULT = CircularProgressDrawable.DEFAULT;
76
77    public static final int DEFAULT_SLINGSHOT_DISTANCE = -1;
78
79    @VisibleForTesting
80    static final int CIRCLE_DIAMETER = 40;
81    @VisibleForTesting
82    static final int CIRCLE_DIAMETER_LARGE = 56;
83
84    private static final String LOG_TAG = SwipeRefreshLayout.class.getSimpleName();
85
86    private static final int MAX_ALPHA = 255;
87    private static final int STARTING_PROGRESS_ALPHA = (int) (.3f * MAX_ALPHA);
88
89    private static final float DECELERATE_INTERPOLATION_FACTOR = 2f;
90    private static final int INVALID_POINTER = -1;
91    private static final float DRAG_RATE = .5f;
92
93    // Max amount of circle that can be filled by progress during swipe gesture,
94    // where 1.0 is a full circle
95    private static final float MAX_PROGRESS_ANGLE = .8f;
96
97    private static final int SCALE_DOWN_DURATION = 150;
98
99    private static final int ALPHA_ANIMATION_DURATION = 300;
100
101    private static final int ANIMATE_TO_TRIGGER_DURATION = 200;
102
103    private static final int ANIMATE_TO_START_DURATION = 200;
104
105    // Default background for the progress spinner
106    private static final int CIRCLE_BG_LIGHT = 0xFFFAFAFA;
107    // Default offset in dips from the top of the view to where the progress spinner should stop
108    private static final int DEFAULT_CIRCLE_TARGET = 64;
109
110    private View mTarget; // the target of the gesture
111    OnRefreshListener mListener;
112    boolean mRefreshing = false;
113    private int mTouchSlop;
114    private float mTotalDragDistance = -1;
115
116    // If nested scrolling is enabled, the total amount that needed to be
117    // consumed by this as the nested scrolling parent is used in place of the
118    // overscroll determined by MOVE events in the onTouch handler
119    private float mTotalUnconsumed;
120    private final NestedScrollingParentHelper mNestedScrollingParentHelper;
121    private final NestedScrollingChildHelper mNestedScrollingChildHelper;
122    private final int[] mParentScrollConsumed = new int[2];
123    private final int[] mParentOffsetInWindow = new int[2];
124    private boolean mNestedScrollInProgress;
125
126    private int mMediumAnimationDuration;
127    int mCurrentTargetOffsetTop;
128
129    private float mInitialMotionY;
130    private float mInitialDownY;
131    private boolean mIsBeingDragged;
132    private int mActivePointerId = INVALID_POINTER;
133    // Whether this item is scaled up rather than clipped
134    boolean mScale;
135
136    // Target is returning to its start offset because it was cancelled or a
137    // refresh was triggered.
138    private boolean mReturningToStart;
139    private final DecelerateInterpolator mDecelerateInterpolator;
140    private static final int[] LAYOUT_ATTRS = new int[] {
141        android.R.attr.enabled
142    };
143
144    CircleImageView mCircleView;
145    private int mCircleViewIndex = -1;
146
147    protected int mFrom;
148
149    float mStartingScale;
150
151    protected int mOriginalOffsetTop;
152
153    int mSpinnerOffsetEnd;
154
155    int mCustomSlingshotDistance;
156
157    CircularProgressDrawable mProgress;
158
159    private Animation mScaleAnimation;
160
161    private Animation mScaleDownAnimation;
162
163    private Animation mAlphaStartAnimation;
164
165    private Animation mAlphaMaxAnimation;
166
167    private Animation mScaleDownToStartAnimation;
168
169    boolean mNotify;
170
171    private int mCircleDiameter;
172
173    // Whether the client has set a custom starting position;
174    boolean mUsingCustomStart;
175
176    private OnChildScrollUpCallback mChildScrollUpCallback;
177
178    private Animation.AnimationListener mRefreshListener = new Animation.AnimationListener() {
179        @Override
180        public void onAnimationStart(Animation animation) {
181        }
182
183        @Override
184        public void onAnimationRepeat(Animation animation) {
185        }
186
187        @Override
188        public void onAnimationEnd(Animation animation) {
189            if (mRefreshing) {
190                // Make sure the progress view is fully visible
191                mProgress.setAlpha(MAX_ALPHA);
192                mProgress.start();
193                if (mNotify) {
194                    if (mListener != null) {
195                        mListener.onRefresh();
196                    }
197                }
198                mCurrentTargetOffsetTop = mCircleView.getTop();
199            } else {
200                reset();
201            }
202        }
203    };
204
205    void reset() {
206        mCircleView.clearAnimation();
207        mProgress.stop();
208        mCircleView.setVisibility(View.GONE);
209        setColorViewAlpha(MAX_ALPHA);
210        // Return the circle to its start position
211        if (mScale) {
212            setAnimationProgress(0 /* animation complete and view is hidden */);
213        } else {
214            setTargetOffsetTopAndBottom(mOriginalOffsetTop - mCurrentTargetOffsetTop);
215        }
216        mCurrentTargetOffsetTop = mCircleView.getTop();
217    }
218
219    @Override
220    public void setEnabled(boolean enabled) {
221        super.setEnabled(enabled);
222        if (!enabled) {
223            reset();
224        }
225    }
226
227    @Override
228    protected void onDetachedFromWindow() {
229        super.onDetachedFromWindow();
230        reset();
231    }
232
233    private void setColorViewAlpha(int targetAlpha) {
234        mCircleView.getBackground().setAlpha(targetAlpha);
235        mProgress.setAlpha(targetAlpha);
236    }
237
238    /**
239     * The refresh indicator starting and resting position is always positioned
240     * near the top of the refreshing content. This position is a consistent
241     * location, but can be adjusted in either direction based on whether or not
242     * there is a toolbar or actionbar present.
243     * <p>
244     * <strong>Note:</strong> Calling this will reset the position of the refresh indicator to
245     * <code>start</code>.
246     * </p>
247     *
248     * @param scale Set to true if there is no view at a higher z-order than where the progress
249     *              spinner is set to appear. Setting it to true will cause indicator to be scaled
250     *              up rather than clipped.
251     * @param start The offset in pixels from the top of this view at which the
252     *              progress spinner should appear.
253     * @param end The offset in pixels from the top of this view at which the
254     *            progress spinner should come to rest after a successful swipe
255     *            gesture.
256     */
257    public void setProgressViewOffset(boolean scale, int start, int end) {
258        mScale = scale;
259        mOriginalOffsetTop = start;
260        mSpinnerOffsetEnd = end;
261        mUsingCustomStart = true;
262        reset();
263        mRefreshing = false;
264    }
265
266    /**
267     * @return The offset in pixels from the top of this view at which the progress spinner should
268     *         appear.
269     */
270    public int getProgressViewStartOffset() {
271        return mOriginalOffsetTop;
272    }
273
274    /**
275     * @return The offset in pixels from the top of this view at which the progress spinner should
276     *         come to rest after a successful swipe gesture.
277     */
278    public int getProgressViewEndOffset() {
279        return mSpinnerOffsetEnd;
280    }
281
282    /**
283     * The refresh indicator resting position is always positioned near the top
284     * of the refreshing content. This position is a consistent location, but
285     * can be adjusted in either direction based on whether or not there is a
286     * toolbar or actionbar present.
287     *
288     * @param scale Set to true if there is no view at a higher z-order than where the progress
289     *              spinner is set to appear. Setting it to true will cause indicator to be scaled
290     *              up rather than clipped.
291     * @param end The offset in pixels from the top of this view at which the
292     *            progress spinner should come to rest after a successful swipe
293     *            gesture.
294     */
295    public void setProgressViewEndTarget(boolean scale, int end) {
296        mSpinnerOffsetEnd = end;
297        mScale = scale;
298        mCircleView.invalidate();
299    }
300
301    /**
302     * Sets a custom slingshot distance.
303     *
304     * @param slingshotDistance The distance in pixels that the refresh indicator can be pulled
305     *                          beyond its resting position. Use
306     *                          {@link #DEFAULT_SLINGSHOT_DISTANCE} to reset to the default value.
307     *
308     */
309    public void setSlingshotDistance(@Px int slingshotDistance) {
310        mCustomSlingshotDistance = slingshotDistance;
311    }
312
313    /**
314     * One of DEFAULT, or LARGE.
315     */
316    public void setSize(int size) {
317        if (size != CircularProgressDrawable.LARGE && size != CircularProgressDrawable.DEFAULT) {
318            return;
319        }
320        final DisplayMetrics metrics = getResources().getDisplayMetrics();
321        if (size == CircularProgressDrawable.LARGE) {
322            mCircleDiameter = (int) (CIRCLE_DIAMETER_LARGE * metrics.density);
323        } else {
324            mCircleDiameter = (int) (CIRCLE_DIAMETER * metrics.density);
325        }
326        // force the bounds of the progress circle inside the circle view to
327        // update by setting it to null before updating its size and then
328        // re-setting it
329        mCircleView.setImageDrawable(null);
330        mProgress.setStyle(size);
331        mCircleView.setImageDrawable(mProgress);
332    }
333
334    /**
335     * Simple constructor to use when creating a SwipeRefreshLayout from code.
336     *
337     * @param context
338     */
339    public SwipeRefreshLayout(@NonNull Context context) {
340        this(context, null);
341    }
342
343    /**
344     * Constructor that is called when inflating SwipeRefreshLayout from XML.
345     *
346     * @param context
347     * @param attrs
348     */
349    public SwipeRefreshLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
350        super(context, attrs);
351
352        mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
353
354        mMediumAnimationDuration = getResources().getInteger(
355                android.R.integer.config_mediumAnimTime);
356
357        setWillNotDraw(false);
358        mDecelerateInterpolator = new DecelerateInterpolator(DECELERATE_INTERPOLATION_FACTOR);
359
360        final DisplayMetrics metrics = getResources().getDisplayMetrics();
361        mCircleDiameter = (int) (CIRCLE_DIAMETER * metrics.density);
362
363        createProgressView();
364        setChildrenDrawingOrderEnabled(true);
365        // the absolute offset has to take into account that the circle starts at an offset
366        mSpinnerOffsetEnd = (int) (DEFAULT_CIRCLE_TARGET * metrics.density);
367        mTotalDragDistance = mSpinnerOffsetEnd;
368        mNestedScrollingParentHelper = new NestedScrollingParentHelper(this);
369
370        mNestedScrollingChildHelper = new NestedScrollingChildHelper(this);
371        setNestedScrollingEnabled(true);
372
373        mOriginalOffsetTop = mCurrentTargetOffsetTop = -mCircleDiameter;
374        moveToStart(1.0f);
375
376        final TypedArray a = context.obtainStyledAttributes(attrs, LAYOUT_ATTRS);
377        setEnabled(a.getBoolean(0, true));
378        a.recycle();
379    }
380
381    @Override
382    protected int getChildDrawingOrder(int childCount, int i) {
383        if (mCircleViewIndex < 0) {
384            return i;
385        } else if (i == childCount - 1) {
386            // Draw the selected child last
387            return mCircleViewIndex;
388        } else if (i >= mCircleViewIndex) {
389            // Move the children after the selected child earlier one
390            return i + 1;
391        } else {
392            // Keep the children before the selected child the same
393            return i;
394        }
395    }
396
397    private void createProgressView() {
398        mCircleView = new CircleImageView(getContext(), CIRCLE_BG_LIGHT);
399        mProgress = new CircularProgressDrawable(getContext());
400        mProgress.setStyle(CircularProgressDrawable.DEFAULT);
401        mCircleView.setImageDrawable(mProgress);
402        mCircleView.setVisibility(View.GONE);
403        addView(mCircleView);
404    }
405
406    /**
407     * Set the listener to be notified when a refresh is triggered via the swipe
408     * gesture.
409     */
410    public void setOnRefreshListener(@Nullable OnRefreshListener listener) {
411        mListener = listener;
412    }
413
414    /**
415     * Notify the widget that refresh state has changed. Do not call this when
416     * refresh is triggered by a swipe gesture.
417     *
418     * @param refreshing Whether or not the view should show refresh progress.
419     */
420    public void setRefreshing(boolean refreshing) {
421        if (refreshing && mRefreshing != refreshing) {
422            // scale and show
423            mRefreshing = refreshing;
424            int endTarget = 0;
425            if (!mUsingCustomStart) {
426                endTarget = mSpinnerOffsetEnd + mOriginalOffsetTop;
427            } else {
428                endTarget = mSpinnerOffsetEnd;
429            }
430            setTargetOffsetTopAndBottom(endTarget - mCurrentTargetOffsetTop);
431            mNotify = false;
432            startScaleUpAnimation(mRefreshListener);
433        } else {
434            setRefreshing(refreshing, false /* notify */);
435        }
436    }
437
438    private void startScaleUpAnimation(AnimationListener listener) {
439        mCircleView.setVisibility(View.VISIBLE);
440        mProgress.setAlpha(MAX_ALPHA);
441        mScaleAnimation = new Animation() {
442            @Override
443            public void applyTransformation(float interpolatedTime, Transformation t) {
444                setAnimationProgress(interpolatedTime);
445            }
446        };
447        mScaleAnimation.setDuration(mMediumAnimationDuration);
448        if (listener != null) {
449            mCircleView.setAnimationListener(listener);
450        }
451        mCircleView.clearAnimation();
452        mCircleView.startAnimation(mScaleAnimation);
453    }
454
455    /**
456     * Pre API 11, this does an alpha animation.
457     * @param progress
458     */
459    void setAnimationProgress(float progress) {
460        mCircleView.setScaleX(progress);
461        mCircleView.setScaleY(progress);
462    }
463
464    private void setRefreshing(boolean refreshing, final boolean notify) {
465        if (mRefreshing != refreshing) {
466            mNotify = notify;
467            ensureTarget();
468            mRefreshing = refreshing;
469            if (mRefreshing) {
470                animateOffsetToCorrectPosition(mCurrentTargetOffsetTop, mRefreshListener);
471            } else {
472                startScaleDownAnimation(mRefreshListener);
473            }
474        }
475    }
476
477    void startScaleDownAnimation(Animation.AnimationListener listener) {
478        mScaleDownAnimation = new Animation() {
479            @Override
480            public void applyTransformation(float interpolatedTime, Transformation t) {
481                setAnimationProgress(1 - interpolatedTime);
482            }
483        };
484        mScaleDownAnimation.setDuration(SCALE_DOWN_DURATION);
485        mCircleView.setAnimationListener(listener);
486        mCircleView.clearAnimation();
487        mCircleView.startAnimation(mScaleDownAnimation);
488    }
489
490    private void startProgressAlphaStartAnimation() {
491        mAlphaStartAnimation = startAlphaAnimation(mProgress.getAlpha(), STARTING_PROGRESS_ALPHA);
492    }
493
494    private void startProgressAlphaMaxAnimation() {
495        mAlphaMaxAnimation = startAlphaAnimation(mProgress.getAlpha(), MAX_ALPHA);
496    }
497
498    private Animation startAlphaAnimation(final int startingAlpha, final int endingAlpha) {
499        Animation alpha = new Animation() {
500            @Override
501            public void applyTransformation(float interpolatedTime, Transformation t) {
502                mProgress.setAlpha(
503                        (int) (startingAlpha + ((endingAlpha - startingAlpha) * interpolatedTime)));
504            }
505        };
506        alpha.setDuration(ALPHA_ANIMATION_DURATION);
507        // Clear out the previous animation listeners.
508        mCircleView.setAnimationListener(null);
509        mCircleView.clearAnimation();
510        mCircleView.startAnimation(alpha);
511        return alpha;
512    }
513
514    /**
515     * @deprecated Use {@link #setProgressBackgroundColorSchemeResource(int)}
516     */
517    @Deprecated
518    public void setProgressBackgroundColor(int colorRes) {
519        setProgressBackgroundColorSchemeResource(colorRes);
520    }
521
522    /**
523     * Set the background color of the progress spinner disc.
524     *
525     * @param colorRes Resource id of the color.
526     */
527    public void setProgressBackgroundColorSchemeResource(@ColorRes int colorRes) {
528        setProgressBackgroundColorSchemeColor(ContextCompat.getColor(getContext(), colorRes));
529    }
530
531    /**
532     * Set the background color of the progress spinner disc.
533     *
534     * @param color
535     */
536    public void setProgressBackgroundColorSchemeColor(@ColorInt int color) {
537        mCircleView.setBackgroundColor(color);
538    }
539
540    /**
541     * @deprecated Use {@link #setColorSchemeResources(int...)}
542     */
543    @Deprecated
544    public void setColorScheme(@ColorRes int... colors) {
545        setColorSchemeResources(colors);
546    }
547
548    /**
549     * Set the color resources used in the progress animation from color resources.
550     * The first color will also be the color of the bar that grows in response
551     * to a user swipe gesture.
552     *
553     * @param colorResIds
554     */
555    public void setColorSchemeResources(@ColorRes int... colorResIds) {
556        final Context context = getContext();
557        int[] colorRes = new int[colorResIds.length];
558        for (int i = 0; i < colorResIds.length; i++) {
559            colorRes[i] = ContextCompat.getColor(context, colorResIds[i]);
560        }
561        setColorSchemeColors(colorRes);
562    }
563
564    /**
565     * Set the colors used in the progress animation. The first
566     * color will also be the color of the bar that grows in response to a user
567     * swipe gesture.
568     *
569     * @param colors
570     */
571    public void setColorSchemeColors(@ColorInt int... colors) {
572        ensureTarget();
573        mProgress.setColorSchemeColors(colors);
574    }
575
576    /**
577     * @return Whether the SwipeRefreshWidget is actively showing refresh
578     *         progress.
579     */
580    public boolean isRefreshing() {
581        return mRefreshing;
582    }
583
584    private void ensureTarget() {
585        // Don't bother getting the parent height if the parent hasn't been laid
586        // out yet.
587        if (mTarget == null) {
588            for (int i = 0; i < getChildCount(); i++) {
589                View child = getChildAt(i);
590                if (!child.equals(mCircleView)) {
591                    mTarget = child;
592                    break;
593                }
594            }
595        }
596    }
597
598    /**
599     * Set the distance to trigger a sync in dips
600     *
601     * @param distance
602     */
603    public void setDistanceToTriggerSync(int distance) {
604        mTotalDragDistance = distance;
605    }
606
607    @Override
608    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
609        final int width = getMeasuredWidth();
610        final int height = getMeasuredHeight();
611        if (getChildCount() == 0) {
612            return;
613        }
614        if (mTarget == null) {
615            ensureTarget();
616        }
617        if (mTarget == null) {
618            return;
619        }
620        final View child = mTarget;
621        final int childLeft = getPaddingLeft();
622        final int childTop = getPaddingTop();
623        final int childWidth = width - getPaddingLeft() - getPaddingRight();
624        final int childHeight = height - getPaddingTop() - getPaddingBottom();
625        child.layout(childLeft, childTop, childLeft + childWidth, childTop + childHeight);
626        int circleWidth = mCircleView.getMeasuredWidth();
627        int circleHeight = mCircleView.getMeasuredHeight();
628        mCircleView.layout((width / 2 - circleWidth / 2), mCurrentTargetOffsetTop,
629                (width / 2 + circleWidth / 2), mCurrentTargetOffsetTop + circleHeight);
630    }
631
632    @Override
633    public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
634        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
635        if (mTarget == null) {
636            ensureTarget();
637        }
638        if (mTarget == null) {
639            return;
640        }
641        mTarget.measure(MeasureSpec.makeMeasureSpec(
642                getMeasuredWidth() - getPaddingLeft() - getPaddingRight(),
643                MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(
644                getMeasuredHeight() - getPaddingTop() - getPaddingBottom(), MeasureSpec.EXACTLY));
645        mCircleView.measure(MeasureSpec.makeMeasureSpec(mCircleDiameter, MeasureSpec.EXACTLY),
646                MeasureSpec.makeMeasureSpec(mCircleDiameter, MeasureSpec.EXACTLY));
647        mCircleViewIndex = -1;
648        // Get the index of the circleview.
649        for (int index = 0; index < getChildCount(); index++) {
650            if (getChildAt(index) == mCircleView) {
651                mCircleViewIndex = index;
652                break;
653            }
654        }
655    }
656
657    /**
658     * Get the diameter of the progress circle that is displayed as part of the
659     * swipe to refresh layout.
660     *
661     * @return Diameter in pixels of the progress circle view.
662     */
663    public int getProgressCircleDiameter() {
664        return mCircleDiameter;
665    }
666
667    /**
668     * @return Whether it is possible for the child view of this layout to
669     *         scroll up. Override this if the child view is a custom view.
670     */
671    public boolean canChildScrollUp() {
672        if (mChildScrollUpCallback != null) {
673            return mChildScrollUpCallback.canChildScrollUp(this, mTarget);
674        }
675        if (mTarget instanceof ListView) {
676            return ListViewCompat.canScrollList((ListView) mTarget, -1);
677        }
678        return mTarget.canScrollVertically(-1);
679    }
680
681    /**
682     * Set a callback to override {@link SwipeRefreshLayout#canChildScrollUp()} method. Non-null
683     * callback will return the value provided by the callback and ignore all internal logic.
684     * @param callback Callback that should be called when canChildScrollUp() is called.
685     */
686    public void setOnChildScrollUpCallback(@Nullable OnChildScrollUpCallback callback) {
687        mChildScrollUpCallback = callback;
688    }
689
690    @Override
691    public boolean onInterceptTouchEvent(MotionEvent ev) {
692        ensureTarget();
693
694        final int action = ev.getActionMasked();
695        int pointerIndex;
696
697        if (mReturningToStart && action == MotionEvent.ACTION_DOWN) {
698            mReturningToStart = false;
699        }
700
701        if (!isEnabled() || mReturningToStart || canChildScrollUp()
702                || mRefreshing || mNestedScrollInProgress) {
703            // Fail fast if we're not in a state where a swipe is possible
704            return false;
705        }
706
707        switch (action) {
708            case MotionEvent.ACTION_DOWN:
709                setTargetOffsetTopAndBottom(mOriginalOffsetTop - mCircleView.getTop());
710                mActivePointerId = ev.getPointerId(0);
711                mIsBeingDragged = false;
712
713                pointerIndex = ev.findPointerIndex(mActivePointerId);
714                if (pointerIndex < 0) {
715                    return false;
716                }
717                mInitialDownY = ev.getY(pointerIndex);
718                break;
719
720            case MotionEvent.ACTION_MOVE:
721                if (mActivePointerId == INVALID_POINTER) {
722                    Log.e(LOG_TAG, "Got ACTION_MOVE event but don't have an active pointer id.");
723                    return false;
724                }
725
726                pointerIndex = ev.findPointerIndex(mActivePointerId);
727                if (pointerIndex < 0) {
728                    return false;
729                }
730                final float y = ev.getY(pointerIndex);
731                startDragging(y);
732                break;
733
734            case MotionEvent.ACTION_POINTER_UP:
735                onSecondaryPointerUp(ev);
736                break;
737
738            case MotionEvent.ACTION_UP:
739            case MotionEvent.ACTION_CANCEL:
740                mIsBeingDragged = false;
741                mActivePointerId = INVALID_POINTER;
742                break;
743        }
744
745        return mIsBeingDragged;
746    }
747
748    @Override
749    public void requestDisallowInterceptTouchEvent(boolean b) {
750        // if this is a List < L or another view that doesn't support nested
751        // scrolling, ignore this request so that the vertical scroll event
752        // isn't stolen
753        if ((android.os.Build.VERSION.SDK_INT < 21 && mTarget instanceof AbsListView)
754                || (mTarget != null && !ViewCompat.isNestedScrollingEnabled(mTarget))) {
755            // Nope.
756        } else {
757            super.requestDisallowInterceptTouchEvent(b);
758        }
759    }
760
761    // NestedScrollingParent
762
763    @Override
764    public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {
765        return isEnabled() && !mReturningToStart && !mRefreshing
766                && (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
767    }
768
769    @Override
770    public void onNestedScrollAccepted(View child, View target, int axes) {
771        // Reset the counter of how much leftover scroll needs to be consumed.
772        mNestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes);
773        // Dispatch up to the nested parent
774        startNestedScroll(axes & ViewCompat.SCROLL_AXIS_VERTICAL);
775        mTotalUnconsumed = 0;
776        mNestedScrollInProgress = true;
777    }
778
779    @Override
780    public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
781        // If we are in the middle of consuming, a scroll, then we want to move the spinner back up
782        // before allowing the list to scroll
783        if (dy > 0 && mTotalUnconsumed > 0) {
784            if (dy > mTotalUnconsumed) {
785                consumed[1] = dy - (int) mTotalUnconsumed;
786                mTotalUnconsumed = 0;
787            } else {
788                mTotalUnconsumed -= dy;
789                consumed[1] = dy;
790            }
791            moveSpinner(mTotalUnconsumed);
792        }
793
794        // If a client layout is using a custom start position for the circle
795        // view, they mean to hide it again before scrolling the child view
796        // If we get back to mTotalUnconsumed == 0 and there is more to go, hide
797        // the circle so it isn't exposed if its blocking content is moved
798        if (mUsingCustomStart && dy > 0 && mTotalUnconsumed == 0
799                && Math.abs(dy - consumed[1]) > 0) {
800            mCircleView.setVisibility(View.GONE);
801        }
802
803        // Now let our nested parent consume the leftovers
804        final int[] parentConsumed = mParentScrollConsumed;
805        if (dispatchNestedPreScroll(dx - consumed[0], dy - consumed[1], parentConsumed, null)) {
806            consumed[0] += parentConsumed[0];
807            consumed[1] += parentConsumed[1];
808        }
809    }
810
811    @Override
812    public int getNestedScrollAxes() {
813        return mNestedScrollingParentHelper.getNestedScrollAxes();
814    }
815
816    @Override
817    public void onStopNestedScroll(View target) {
818        mNestedScrollingParentHelper.onStopNestedScroll(target);
819        mNestedScrollInProgress = false;
820        // Finish the spinner for nested scrolling if we ever consumed any
821        // unconsumed nested scroll
822        if (mTotalUnconsumed > 0) {
823            finishSpinner(mTotalUnconsumed);
824            mTotalUnconsumed = 0;
825        }
826        // Dispatch up our nested parent
827        stopNestedScroll();
828    }
829
830    @Override
831    public void onNestedScroll(final View target, final int dxConsumed, final int dyConsumed,
832            final int dxUnconsumed, final int dyUnconsumed) {
833        // Dispatch up to the nested parent first
834        dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
835                mParentOffsetInWindow);
836
837        // This is a bit of a hack. Nested scrolling works from the bottom up, and as we are
838        // sometimes between two nested scrolling views, we need a way to be able to know when any
839        // nested scrolling parent has stopped handling events. We do that by using the
840        // 'offset in window 'functionality to see if we have been moved from the event.
841        // This is a decent indication of whether we should take over the event stream or not.
842        final int dy = dyUnconsumed + mParentOffsetInWindow[1];
843        if (dy < 0 && !canChildScrollUp()) {
844            mTotalUnconsumed += Math.abs(dy);
845            moveSpinner(mTotalUnconsumed);
846        }
847    }
848
849    // NestedScrollingChild
850
851    @Override
852    public void setNestedScrollingEnabled(boolean enabled) {
853        mNestedScrollingChildHelper.setNestedScrollingEnabled(enabled);
854    }
855
856    @Override
857    public boolean isNestedScrollingEnabled() {
858        return mNestedScrollingChildHelper.isNestedScrollingEnabled();
859    }
860
861    @Override
862    public boolean startNestedScroll(int axes) {
863        return mNestedScrollingChildHelper.startNestedScroll(axes);
864    }
865
866    @Override
867    public void stopNestedScroll() {
868        mNestedScrollingChildHelper.stopNestedScroll();
869    }
870
871    @Override
872    public boolean hasNestedScrollingParent() {
873        return mNestedScrollingChildHelper.hasNestedScrollingParent();
874    }
875
876    @Override
877    public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
878            int dyUnconsumed, int[] offsetInWindow) {
879        return mNestedScrollingChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed,
880                dxUnconsumed, dyUnconsumed, offsetInWindow);
881    }
882
883    @Override
884    public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) {
885        return mNestedScrollingChildHelper.dispatchNestedPreScroll(
886                dx, dy, consumed, offsetInWindow);
887    }
888
889    @Override
890    public boolean onNestedPreFling(View target, float velocityX,
891            float velocityY) {
892        return dispatchNestedPreFling(velocityX, velocityY);
893    }
894
895    @Override
896    public boolean onNestedFling(View target, float velocityX, float velocityY,
897            boolean consumed) {
898        return dispatchNestedFling(velocityX, velocityY, consumed);
899    }
900
901    @Override
902    public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) {
903        return mNestedScrollingChildHelper.dispatchNestedFling(velocityX, velocityY, consumed);
904    }
905
906    @Override
907    public boolean dispatchNestedPreFling(float velocityX, float velocityY) {
908        return mNestedScrollingChildHelper.dispatchNestedPreFling(velocityX, velocityY);
909    }
910
911    private boolean isAnimationRunning(Animation animation) {
912        return animation != null && animation.hasStarted() && !animation.hasEnded();
913    }
914
915    private void moveSpinner(float overscrollTop) {
916        mProgress.setArrowEnabled(true);
917        float originalDragPercent = overscrollTop / mTotalDragDistance;
918
919        float dragPercent = Math.min(1f, Math.abs(originalDragPercent));
920        float adjustedPercent = (float) Math.max(dragPercent - .4, 0) * 5 / 3;
921        float extraOS = Math.abs(overscrollTop) - mTotalDragDistance;
922        float slingshotDist = mCustomSlingshotDistance > 0
923                ? mCustomSlingshotDistance
924                : (mUsingCustomStart
925                        ? mSpinnerOffsetEnd - mOriginalOffsetTop
926                        : mSpinnerOffsetEnd);
927        float tensionSlingshotPercent = Math.max(0, Math.min(extraOS, slingshotDist * 2)
928                / slingshotDist);
929        float tensionPercent = (float) ((tensionSlingshotPercent / 4) - Math.pow(
930                (tensionSlingshotPercent / 4), 2)) * 2f;
931        float extraMove = (slingshotDist) * tensionPercent * 2;
932
933        int targetY = mOriginalOffsetTop + (int) ((slingshotDist * dragPercent) + extraMove);
934        // where 1.0f is a full circle
935        if (mCircleView.getVisibility() != View.VISIBLE) {
936            mCircleView.setVisibility(View.VISIBLE);
937        }
938        if (!mScale) {
939            mCircleView.setScaleX(1f);
940            mCircleView.setScaleY(1f);
941        }
942
943        if (mScale) {
944            setAnimationProgress(Math.min(1f, overscrollTop / mTotalDragDistance));
945        }
946        if (overscrollTop < mTotalDragDistance) {
947            if (mProgress.getAlpha() > STARTING_PROGRESS_ALPHA
948                    && !isAnimationRunning(mAlphaStartAnimation)) {
949                // Animate the alpha
950                startProgressAlphaStartAnimation();
951            }
952        } else {
953            if (mProgress.getAlpha() < MAX_ALPHA && !isAnimationRunning(mAlphaMaxAnimation)) {
954                // Animate the alpha
955                startProgressAlphaMaxAnimation();
956            }
957        }
958        float strokeStart = adjustedPercent * .8f;
959        mProgress.setStartEndTrim(0f, Math.min(MAX_PROGRESS_ANGLE, strokeStart));
960        mProgress.setArrowScale(Math.min(1f, adjustedPercent));
961
962        float rotation = (-0.25f + .4f * adjustedPercent + tensionPercent * 2) * .5f;
963        mProgress.setProgressRotation(rotation);
964        setTargetOffsetTopAndBottom(targetY - mCurrentTargetOffsetTop);
965    }
966
967    private void finishSpinner(float overscrollTop) {
968        if (overscrollTop > mTotalDragDistance) {
969            setRefreshing(true, true /* notify */);
970        } else {
971            // cancel refresh
972            mRefreshing = false;
973            mProgress.setStartEndTrim(0f, 0f);
974            Animation.AnimationListener listener = null;
975            if (!mScale) {
976                listener = new Animation.AnimationListener() {
977
978                    @Override
979                    public void onAnimationStart(Animation animation) {
980                    }
981
982                    @Override
983                    public void onAnimationEnd(Animation animation) {
984                        if (!mScale) {
985                            startScaleDownAnimation(null);
986                        }
987                    }
988
989                    @Override
990                    public void onAnimationRepeat(Animation animation) {
991                    }
992
993                };
994            }
995            animateOffsetToStartPosition(mCurrentTargetOffsetTop, listener);
996            mProgress.setArrowEnabled(false);
997        }
998    }
999
1000    @Override
1001    public boolean onTouchEvent(MotionEvent ev) {
1002        final int action = ev.getActionMasked();
1003        int pointerIndex = -1;
1004
1005        if (mReturningToStart && action == MotionEvent.ACTION_DOWN) {
1006            mReturningToStart = false;
1007        }
1008
1009        if (!isEnabled() || mReturningToStart || canChildScrollUp()
1010                || mRefreshing || mNestedScrollInProgress) {
1011            // Fail fast if we're not in a state where a swipe is possible
1012            return false;
1013        }
1014
1015        switch (action) {
1016            case MotionEvent.ACTION_DOWN:
1017                mActivePointerId = ev.getPointerId(0);
1018                mIsBeingDragged = false;
1019                break;
1020
1021            case MotionEvent.ACTION_MOVE: {
1022                pointerIndex = ev.findPointerIndex(mActivePointerId);
1023                if (pointerIndex < 0) {
1024                    Log.e(LOG_TAG, "Got ACTION_MOVE event but have an invalid active pointer id.");
1025                    return false;
1026                }
1027
1028                final float y = ev.getY(pointerIndex);
1029                startDragging(y);
1030
1031                if (mIsBeingDragged) {
1032                    final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE;
1033                    if (overscrollTop > 0) {
1034                        moveSpinner(overscrollTop);
1035                    } else {
1036                        return false;
1037                    }
1038                }
1039                break;
1040            }
1041            case MotionEvent.ACTION_POINTER_DOWN: {
1042                pointerIndex = ev.getActionIndex();
1043                if (pointerIndex < 0) {
1044                    Log.e(LOG_TAG,
1045                            "Got ACTION_POINTER_DOWN event but have an invalid action index.");
1046                    return false;
1047                }
1048                mActivePointerId = ev.getPointerId(pointerIndex);
1049                break;
1050            }
1051
1052            case MotionEvent.ACTION_POINTER_UP:
1053                onSecondaryPointerUp(ev);
1054                break;
1055
1056            case MotionEvent.ACTION_UP: {
1057                pointerIndex = ev.findPointerIndex(mActivePointerId);
1058                if (pointerIndex < 0) {
1059                    Log.e(LOG_TAG, "Got ACTION_UP event but don't have an active pointer id.");
1060                    return false;
1061                }
1062
1063                if (mIsBeingDragged) {
1064                    final float y = ev.getY(pointerIndex);
1065                    final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE;
1066                    mIsBeingDragged = false;
1067                    finishSpinner(overscrollTop);
1068                }
1069                mActivePointerId = INVALID_POINTER;
1070                return false;
1071            }
1072            case MotionEvent.ACTION_CANCEL:
1073                return false;
1074        }
1075
1076        return true;
1077    }
1078
1079    private void startDragging(float y) {
1080        final float yDiff = y - mInitialDownY;
1081        if (yDiff > mTouchSlop && !mIsBeingDragged) {
1082            mInitialMotionY = mInitialDownY + mTouchSlop;
1083            mIsBeingDragged = true;
1084            mProgress.setAlpha(STARTING_PROGRESS_ALPHA);
1085        }
1086    }
1087
1088    private void animateOffsetToCorrectPosition(int from, AnimationListener listener) {
1089        mFrom = from;
1090        mAnimateToCorrectPosition.reset();
1091        mAnimateToCorrectPosition.setDuration(ANIMATE_TO_TRIGGER_DURATION);
1092        mAnimateToCorrectPosition.setInterpolator(mDecelerateInterpolator);
1093        if (listener != null) {
1094            mCircleView.setAnimationListener(listener);
1095        }
1096        mCircleView.clearAnimation();
1097        mCircleView.startAnimation(mAnimateToCorrectPosition);
1098    }
1099
1100    private void animateOffsetToStartPosition(int from, AnimationListener listener) {
1101        if (mScale) {
1102            // Scale the item back down
1103            startScaleDownReturnToStartAnimation(from, listener);
1104        } else {
1105            mFrom = from;
1106            mAnimateToStartPosition.reset();
1107            mAnimateToStartPosition.setDuration(ANIMATE_TO_START_DURATION);
1108            mAnimateToStartPosition.setInterpolator(mDecelerateInterpolator);
1109            if (listener != null) {
1110                mCircleView.setAnimationListener(listener);
1111            }
1112            mCircleView.clearAnimation();
1113            mCircleView.startAnimation(mAnimateToStartPosition);
1114        }
1115    }
1116
1117    private final Animation mAnimateToCorrectPosition = new Animation() {
1118        @Override
1119        public void applyTransformation(float interpolatedTime, Transformation t) {
1120            int targetTop = 0;
1121            int endTarget = 0;
1122            if (!mUsingCustomStart) {
1123                endTarget = mSpinnerOffsetEnd - Math.abs(mOriginalOffsetTop);
1124            } else {
1125                endTarget = mSpinnerOffsetEnd;
1126            }
1127            targetTop = (mFrom + (int) ((endTarget - mFrom) * interpolatedTime));
1128            int offset = targetTop - mCircleView.getTop();
1129            setTargetOffsetTopAndBottom(offset);
1130            mProgress.setArrowScale(1 - interpolatedTime);
1131        }
1132    };
1133
1134    void moveToStart(float interpolatedTime) {
1135        int targetTop = 0;
1136        targetTop = (mFrom + (int) ((mOriginalOffsetTop - mFrom) * interpolatedTime));
1137        int offset = targetTop - mCircleView.getTop();
1138        setTargetOffsetTopAndBottom(offset);
1139    }
1140
1141    private final Animation mAnimateToStartPosition = new Animation() {
1142        @Override
1143        public void applyTransformation(float interpolatedTime, Transformation t) {
1144            moveToStart(interpolatedTime);
1145        }
1146    };
1147
1148    private void startScaleDownReturnToStartAnimation(int from,
1149            Animation.AnimationListener listener) {
1150        mFrom = from;
1151        mStartingScale = mCircleView.getScaleX();
1152        mScaleDownToStartAnimation = new Animation() {
1153            @Override
1154            public void applyTransformation(float interpolatedTime, Transformation t) {
1155                float targetScale = (mStartingScale + (-mStartingScale  * interpolatedTime));
1156                setAnimationProgress(targetScale);
1157                moveToStart(interpolatedTime);
1158            }
1159        };
1160        mScaleDownToStartAnimation.setDuration(SCALE_DOWN_DURATION);
1161        if (listener != null) {
1162            mCircleView.setAnimationListener(listener);
1163        }
1164        mCircleView.clearAnimation();
1165        mCircleView.startAnimation(mScaleDownToStartAnimation);
1166    }
1167
1168    void setTargetOffsetTopAndBottom(int offset) {
1169        mCircleView.bringToFront();
1170        ViewCompat.offsetTopAndBottom(mCircleView, offset);
1171        mCurrentTargetOffsetTop = mCircleView.getTop();
1172    }
1173
1174    private void onSecondaryPointerUp(MotionEvent ev) {
1175        final int pointerIndex = ev.getActionIndex();
1176        final int pointerId = ev.getPointerId(pointerIndex);
1177        if (pointerId == mActivePointerId) {
1178            // This was our active pointer going up. Choose a new
1179            // active pointer and adjust accordingly.
1180            final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
1181            mActivePointerId = ev.getPointerId(newPointerIndex);
1182        }
1183    }
1184
1185    /**
1186     * Classes that wish to be notified when the swipe gesture correctly
1187     * triggers a refresh should implement this interface.
1188     */
1189    public interface OnRefreshListener {
1190        /**
1191         * Called when a swipe gesture triggers a refresh.
1192         */
1193        void onRefresh();
1194    }
1195
1196    /**
1197     * Classes that wish to override {@link SwipeRefreshLayout#canChildScrollUp()} method
1198     * behavior should implement this interface.
1199     */
1200    public interface OnChildScrollUpCallback {
1201        /**
1202         * Callback that will be called when {@link SwipeRefreshLayout#canChildScrollUp()} method
1203         * is called to allow the implementer to override its behavior.
1204         *
1205         * @param parent SwipeRefreshLayout that this callback is overriding.
1206         * @param child The child view of SwipeRefreshLayout.
1207         *
1208         * @return Whether it is possible for the child view of parent layout to scroll up.
1209         */
1210        boolean canChildScrollUp(@NonNull SwipeRefreshLayout parent, @Nullable View child);
1211    }
1212}
1213