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