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