SwipeRefreshLayout.java revision 6ed40c1f86bcb172a1f0f069cde1c571a7781aee
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.annotation.SuppressLint;
20import android.content.Context;
21import android.content.res.TypedArray;
22import android.support.annotation.ColorInt;
23import android.support.annotation.ColorRes;
24import android.support.annotation.Nullable;
25import android.support.annotation.VisibleForTesting;
26import android.support.v4.content.ContextCompat;
27import android.support.v4.view.NestedScrollingChild;
28import android.support.v4.view.NestedScrollingChildHelper;
29import android.support.v4.view.NestedScrollingParent;
30import android.support.v4.view.NestedScrollingParentHelper;
31import android.support.v4.view.ViewCompat;
32import android.util.AttributeSet;
33import android.util.DisplayMetrics;
34import android.util.Log;
35import android.view.MotionEvent;
36import android.view.View;
37import android.view.ViewConfiguration;
38import android.view.ViewGroup;
39import android.view.animation.Animation;
40import android.view.animation.Animation.AnimationListener;
41import android.view.animation.DecelerateInterpolator;
42import android.view.animation.Transformation;
43import android.widget.AbsListView;
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        @SuppressLint("NewApi")
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    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        }
209        mCurrentTargetOffsetTop = mCircleView.getTop();
210    }
211
212    @Override
213    public void setEnabled(boolean enabled) {
214        super.setEnabled(enabled);
215        if (!enabled) {
216            reset();
217        }
218    }
219
220    @Override
221    protected void onDetachedFromWindow() {
222        super.onDetachedFromWindow();
223        reset();
224    }
225
226    @SuppressLint("NewApi")
227    private void setColorViewAlpha(int targetAlpha) {
228        mCircleView.getBackground().setAlpha(targetAlpha);
229        mProgress.setAlpha(targetAlpha);
230    }
231
232    /**
233     * The refresh indicator starting and resting position is always positioned
234     * near the top of the refreshing content. This position is a consistent
235     * location, but can be adjusted in either direction based on whether or not
236     * there is a toolbar or actionbar present.
237     * <p>
238     * <strong>Note:</strong> Calling this will reset the position of the refresh indicator to
239     * <code>start</code>.
240     * </p>
241     *
242     * @param scale Set to true if there is no view at a higher z-order than where the progress
243     *              spinner is set to appear. Setting it to true will cause indicator to be scaled
244     *              up rather than clipped.
245     * @param start The offset in pixels from the top of this view at which the
246     *              progress spinner should appear.
247     * @param end The offset in pixels from the top of this view at which the
248     *            progress spinner should come to rest after a successful swipe
249     *            gesture.
250     */
251    public void setProgressViewOffset(boolean scale, int start, int end) {
252        mScale = scale;
253        mOriginalOffsetTop = start;
254        mSpinnerOffsetEnd = end;
255        mUsingCustomStart = true;
256        reset();
257        mRefreshing = false;
258    }
259
260    /**
261     * @return The offset in pixels from the top of this view at which the progress spinner should
262     *         appear.
263     */
264    public int getProgressViewStartOffset() {
265        return mOriginalOffsetTop;
266    }
267
268    /**
269     * @return The offset in pixels from the top of this view at which the progress spinner should
270     *         come to rest after a successful swipe gesture.
271     */
272    public int getProgressViewEndOffset() {
273        return mSpinnerOffsetEnd;
274    }
275
276    /**
277     * The refresh indicator resting position is always positioned near the top
278     * of the refreshing content. This position is a consistent location, but
279     * can be adjusted in either direction based on whether or not there is a
280     * toolbar or actionbar present.
281     *
282     * @param scale Set to true if there is no view at a higher z-order than where the progress
283     *              spinner is set to appear. Setting it to true will cause indicator to be scaled
284     *              up rather than clipped.
285     * @param end The offset in pixels from the top of this view at which the
286     *            progress spinner should come to rest after a successful swipe
287     *            gesture.
288     */
289    public void setProgressViewEndTarget(boolean scale, int end) {
290        mSpinnerOffsetEnd = end;
291        mScale = scale;
292        mCircleView.invalidate();
293    }
294
295    /**
296     * One of DEFAULT, or LARGE.
297     */
298    public void setSize(int size) {
299        if (size != MaterialProgressDrawable.LARGE && size != MaterialProgressDrawable.DEFAULT) {
300            return;
301        }
302        final DisplayMetrics metrics = getResources().getDisplayMetrics();
303        if (size == MaterialProgressDrawable.LARGE) {
304            mCircleDiameter = (int) (CIRCLE_DIAMETER_LARGE * metrics.density);
305        } else {
306            mCircleDiameter = (int) (CIRCLE_DIAMETER * metrics.density);
307        }
308        // force the bounds of the progress circle inside the circle view to
309        // update by setting it to null before updating its size and then
310        // re-setting it
311        mCircleView.setImageDrawable(null);
312        mProgress.updateSizes(size);
313        mCircleView.setImageDrawable(mProgress);
314    }
315
316    /**
317     * Simple constructor to use when creating a SwipeRefreshLayout from code.
318     *
319     * @param context
320     */
321    public SwipeRefreshLayout(Context context) {
322        this(context, null);
323    }
324
325    /**
326     * Constructor that is called when inflating SwipeRefreshLayout from XML.
327     *
328     * @param context
329     * @param attrs
330     */
331    public SwipeRefreshLayout(Context context, AttributeSet attrs) {
332        super(context, attrs);
333
334        mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
335
336        mMediumAnimationDuration = getResources().getInteger(
337                android.R.integer.config_mediumAnimTime);
338
339        setWillNotDraw(false);
340        mDecelerateInterpolator = new DecelerateInterpolator(DECELERATE_INTERPOLATION_FACTOR);
341
342        final DisplayMetrics metrics = getResources().getDisplayMetrics();
343        mCircleDiameter = (int) (CIRCLE_DIAMETER * metrics.density);
344
345        createProgressView();
346        ViewCompat.setChildrenDrawingOrderEnabled(this, true);
347        // the absolute offset has to take into account that the circle starts at an offset
348        mSpinnerOffsetEnd = (int) (DEFAULT_CIRCLE_TARGET * metrics.density);
349        mTotalDragDistance = mSpinnerOffsetEnd;
350        mNestedScrollingParentHelper = new NestedScrollingParentHelper(this);
351
352        mNestedScrollingChildHelper = new NestedScrollingChildHelper(this);
353        setNestedScrollingEnabled(true);
354
355        mOriginalOffsetTop = mCurrentTargetOffsetTop = -mCircleDiameter;
356        moveToStart(1.0f);
357
358        final TypedArray a = context.obtainStyledAttributes(attrs, LAYOUT_ATTRS);
359        setEnabled(a.getBoolean(0, true));
360        a.recycle();
361    }
362
363    @Override
364    protected int getChildDrawingOrder(int childCount, int i) {
365        if (mCircleViewIndex < 0) {
366            return i;
367        } else if (i == childCount - 1) {
368            // Draw the selected child last
369            return mCircleViewIndex;
370        } else if (i >= mCircleViewIndex) {
371            // Move the children after the selected child earlier one
372            return i + 1;
373        } else {
374            // Keep the children before the selected child the same
375            return i;
376        }
377    }
378
379    private void createProgressView() {
380        mCircleView = new CircleImageView(getContext(), CIRCLE_BG_LIGHT);
381        mProgress = new MaterialProgressDrawable(getContext(), this);
382        mProgress.setBackgroundColor(CIRCLE_BG_LIGHT);
383        mCircleView.setImageDrawable(mProgress);
384        mCircleView.setVisibility(View.GONE);
385        addView(mCircleView);
386    }
387
388    /**
389     * Set the listener to be notified when a refresh is triggered via the swipe
390     * gesture.
391     */
392    public void setOnRefreshListener(OnRefreshListener listener) {
393        mListener = listener;
394    }
395
396    /**
397     * Notify the widget that refresh state has changed. Do not call this when
398     * refresh is triggered by a swipe gesture.
399     *
400     * @param refreshing Whether or not the view should show refresh progress.
401     */
402    public void setRefreshing(boolean refreshing) {
403        if (refreshing && mRefreshing != refreshing) {
404            // scale and show
405            mRefreshing = refreshing;
406            int endTarget = 0;
407            if (!mUsingCustomStart) {
408                endTarget = mSpinnerOffsetEnd + mOriginalOffsetTop;
409            } else {
410                endTarget = mSpinnerOffsetEnd;
411            }
412            setTargetOffsetTopAndBottom(endTarget - mCurrentTargetOffsetTop);
413            mNotify = false;
414            startScaleUpAnimation(mRefreshListener);
415        } else {
416            setRefreshing(refreshing, false /* notify */);
417        }
418    }
419
420    @SuppressLint("NewApi")
421    private void startScaleUpAnimation(AnimationListener listener) {
422        mCircleView.setVisibility(View.VISIBLE);
423        if (android.os.Build.VERSION.SDK_INT >= 11) {
424            // Pre API 11, alpha is used in place of scale up to show the
425            // progress circle appearing.
426            // Don't adjust the alpha during appearance otherwise.
427            mProgress.setAlpha(MAX_ALPHA);
428        }
429        mScaleAnimation = new Animation() {
430            @Override
431            public void applyTransformation(float interpolatedTime, Transformation t) {
432                setAnimationProgress(interpolatedTime);
433            }
434        };
435        mScaleAnimation.setDuration(mMediumAnimationDuration);
436        if (listener != null) {
437            mCircleView.setAnimationListener(listener);
438        }
439        mCircleView.clearAnimation();
440        mCircleView.startAnimation(mScaleAnimation);
441    }
442
443    /**
444     * Pre API 11, this does an alpha animation.
445     * @param progress
446     */
447    void setAnimationProgress(float progress) {
448        mCircleView.setScaleX(progress);
449        mCircleView.setScaleY(progress);
450    }
451
452    private void setRefreshing(boolean refreshing, final boolean notify) {
453        if (mRefreshing != refreshing) {
454            mNotify = notify;
455            ensureTarget();
456            mRefreshing = refreshing;
457            if (mRefreshing) {
458                animateOffsetToCorrectPosition(mCurrentTargetOffsetTop, mRefreshListener);
459            } else {
460                startScaleDownAnimation(mRefreshListener);
461            }
462        }
463    }
464
465    void startScaleDownAnimation(Animation.AnimationListener listener) {
466        mScaleDownAnimation = new Animation() {
467            @Override
468            public void applyTransformation(float interpolatedTime, Transformation t) {
469                setAnimationProgress(1 - interpolatedTime);
470            }
471        };
472        mScaleDownAnimation.setDuration(SCALE_DOWN_DURATION);
473        mCircleView.setAnimationListener(listener);
474        mCircleView.clearAnimation();
475        mCircleView.startAnimation(mScaleDownAnimation);
476    }
477
478    private void startProgressAlphaStartAnimation() {
479        mAlphaStartAnimation = startAlphaAnimation(mProgress.getAlpha(), STARTING_PROGRESS_ALPHA);
480    }
481
482    private void startProgressAlphaMaxAnimation() {
483        mAlphaMaxAnimation = startAlphaAnimation(mProgress.getAlpha(), MAX_ALPHA);
484    }
485
486    private Animation startAlphaAnimation(final int startingAlpha, final int endingAlpha) {
487        Animation alpha = new Animation() {
488            @Override
489            public void applyTransformation(float interpolatedTime, Transformation t) {
490                mProgress.setAlpha(
491                        (int) (startingAlpha + ((endingAlpha - startingAlpha) * interpolatedTime)));
492            }
493        };
494        alpha.setDuration(ALPHA_ANIMATION_DURATION);
495        // Clear out the previous animation listeners.
496        mCircleView.setAnimationListener(null);
497        mCircleView.clearAnimation();
498        mCircleView.startAnimation(alpha);
499        return alpha;
500    }
501
502    /**
503     * @deprecated Use {@link #setProgressBackgroundColorSchemeResource(int)}
504     */
505    @Deprecated
506    public void setProgressBackgroundColor(int colorRes) {
507        setProgressBackgroundColorSchemeResource(colorRes);
508    }
509
510    /**
511     * Set the background color of the progress spinner disc.
512     *
513     * @param colorRes Resource id of the color.
514     */
515    public void setProgressBackgroundColorSchemeResource(@ColorRes int colorRes) {
516        setProgressBackgroundColorSchemeColor(ContextCompat.getColor(getContext(), colorRes));
517    }
518
519    /**
520     * Set the background color of the progress spinner disc.
521     *
522     * @param color
523     */
524    public void setProgressBackgroundColorSchemeColor(@ColorInt int color) {
525        mCircleView.setBackgroundColor(color);
526        mProgress.setBackgroundColor(color);
527    }
528
529    /**
530     * @deprecated Use {@link #setColorSchemeResources(int...)}
531     */
532    @Deprecated
533    public void setColorScheme(@ColorInt int... colors) {
534        setColorSchemeResources(colors);
535    }
536
537    /**
538     * Set the color resources used in the progress animation from color resources.
539     * The first color will also be the color of the bar that grows in response
540     * to a user swipe gesture.
541     *
542     * @param colorResIds
543     */
544    public void setColorSchemeResources(@ColorRes int... colorResIds) {
545        final Context context = getContext();
546        int[] colorRes = new int[colorResIds.length];
547        for (int i = 0; i < colorResIds.length; i++) {
548            colorRes[i] = ContextCompat.getColor(context, colorResIds[i]);
549        }
550        setColorSchemeColors(colorRes);
551    }
552
553    /**
554     * Set the colors used in the progress animation. The first
555     * color will also be the color of the bar that grows in response to a user
556     * swipe gesture.
557     *
558     * @param colors
559     */
560    public void setColorSchemeColors(@ColorInt int... colors) {
561        ensureTarget();
562        mProgress.setColorSchemeColors(colors);
563    }
564
565    /**
566     * @return Whether the SwipeRefreshWidget is actively showing refresh
567     *         progress.
568     */
569    public boolean isRefreshing() {
570        return mRefreshing;
571    }
572
573    private void ensureTarget() {
574        // Don't bother getting the parent height if the parent hasn't been laid
575        // out yet.
576        if (mTarget == null) {
577            for (int i = 0; i < getChildCount(); i++) {
578                View child = getChildAt(i);
579                if (!child.equals(mCircleView)) {
580                    mTarget = child;
581                    break;
582                }
583            }
584        }
585    }
586
587    /**
588     * Set the distance to trigger a sync in dips
589     *
590     * @param distance
591     */
592    public void setDistanceToTriggerSync(int distance) {
593        mTotalDragDistance = distance;
594    }
595
596    @Override
597    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
598        final int width = getMeasuredWidth();
599        final int height = getMeasuredHeight();
600        if (getChildCount() == 0) {
601            return;
602        }
603        if (mTarget == null) {
604            ensureTarget();
605        }
606        if (mTarget == null) {
607            return;
608        }
609        final View child = mTarget;
610        final int childLeft = getPaddingLeft();
611        final int childTop = getPaddingTop();
612        final int childWidth = width - getPaddingLeft() - getPaddingRight();
613        final int childHeight = height - getPaddingTop() - getPaddingBottom();
614        child.layout(childLeft, childTop, childLeft + childWidth, childTop + childHeight);
615        int circleWidth = mCircleView.getMeasuredWidth();
616        int circleHeight = mCircleView.getMeasuredHeight();
617        mCircleView.layout((width / 2 - circleWidth / 2), mCurrentTargetOffsetTop,
618                (width / 2 + circleWidth / 2), mCurrentTargetOffsetTop + circleHeight);
619    }
620
621    @Override
622    public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
623        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
624        if (mTarget == null) {
625            ensureTarget();
626        }
627        if (mTarget == null) {
628            return;
629        }
630        mTarget.measure(MeasureSpec.makeMeasureSpec(
631                getMeasuredWidth() - getPaddingLeft() - getPaddingRight(),
632                MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(
633                getMeasuredHeight() - getPaddingTop() - getPaddingBottom(), MeasureSpec.EXACTLY));
634        mCircleView.measure(MeasureSpec.makeMeasureSpec(mCircleDiameter, MeasureSpec.EXACTLY),
635                MeasureSpec.makeMeasureSpec(mCircleDiameter, MeasureSpec.EXACTLY));
636        mCircleViewIndex = -1;
637        // Get the index of the circleview.
638        for (int index = 0; index < getChildCount(); index++) {
639            if (getChildAt(index) == mCircleView) {
640                mCircleViewIndex = index;
641                break;
642            }
643        }
644    }
645
646    /**
647     * Get the diameter of the progress circle that is displayed as part of the
648     * swipe to refresh layout.
649     *
650     * @return Diameter in pixels of the progress circle view.
651     */
652    public int getProgressCircleDiameter() {
653        return mCircleDiameter;
654    }
655
656    /**
657     * @return Whether it is possible for the child view of this layout to
658     *         scroll up. Override this if the child view is a custom view.
659     */
660    public boolean canChildScrollUp() {
661        if (mChildScrollUpCallback != null) {
662            return mChildScrollUpCallback.canChildScrollUp(this, mTarget);
663        }
664        return ViewCompat.canScrollVertically(mTarget, -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    @SuppressLint("NewApi")
902    private void moveSpinner(float overscrollTop) {
903        mProgress.showArrow(true);
904        float originalDragPercent = overscrollTop / mTotalDragDistance;
905
906        float dragPercent = Math.min(1f, Math.abs(originalDragPercent));
907        float adjustedPercent = (float) Math.max(dragPercent - .4, 0) * 5 / 3;
908        float extraOS = Math.abs(overscrollTop) - mTotalDragDistance;
909        float slingshotDist = mUsingCustomStart ? mSpinnerOffsetEnd - mOriginalOffsetTop
910                : mSpinnerOffsetEnd;
911        float tensionSlingshotPercent = Math.max(0, Math.min(extraOS, slingshotDist * 2)
912                / slingshotDist);
913        float tensionPercent = (float) ((tensionSlingshotPercent / 4) - Math.pow(
914                (tensionSlingshotPercent / 4), 2)) * 2f;
915        float extraMove = (slingshotDist) * tensionPercent * 2;
916
917        int targetY = mOriginalOffsetTop + (int) ((slingshotDist * dragPercent) + extraMove);
918        // where 1.0f is a full circle
919        if (mCircleView.getVisibility() != View.VISIBLE) {
920            mCircleView.setVisibility(View.VISIBLE);
921        }
922        if (!mScale) {
923            mCircleView.setScaleX(1f);
924            mCircleView.setScaleY(1f);
925        }
926
927        if (mScale) {
928            setAnimationProgress(Math.min(1f, overscrollTop / mTotalDragDistance));
929        }
930        if (overscrollTop < mTotalDragDistance) {
931            if (mProgress.getAlpha() > STARTING_PROGRESS_ALPHA
932                    && !isAnimationRunning(mAlphaStartAnimation)) {
933                // Animate the alpha
934                startProgressAlphaStartAnimation();
935            }
936        } else {
937            if (mProgress.getAlpha() < MAX_ALPHA && !isAnimationRunning(mAlphaMaxAnimation)) {
938                // Animate the alpha
939                startProgressAlphaMaxAnimation();
940            }
941        }
942        float strokeStart = adjustedPercent * .8f;
943        mProgress.setStartEndTrim(0f, Math.min(MAX_PROGRESS_ANGLE, strokeStart));
944        mProgress.setArrowScale(Math.min(1f, adjustedPercent));
945
946        float rotation = (-0.25f + .4f * adjustedPercent + tensionPercent * 2) * .5f;
947        mProgress.setProgressRotation(rotation);
948        setTargetOffsetTopAndBottom(targetY - mCurrentTargetOffsetTop);
949    }
950
951    private void finishSpinner(float overscrollTop) {
952        if (overscrollTop > mTotalDragDistance) {
953            setRefreshing(true, true /* notify */);
954        } else {
955            // cancel refresh
956            mRefreshing = false;
957            mProgress.setStartEndTrim(0f, 0f);
958            Animation.AnimationListener listener = null;
959            if (!mScale) {
960                listener = new Animation.AnimationListener() {
961
962                    @Override
963                    public void onAnimationStart(Animation animation) {
964                    }
965
966                    @Override
967                    public void onAnimationEnd(Animation animation) {
968                        if (!mScale) {
969                            startScaleDownAnimation(null);
970                        }
971                    }
972
973                    @Override
974                    public void onAnimationRepeat(Animation animation) {
975                    }
976
977                };
978            }
979            animateOffsetToStartPosition(mCurrentTargetOffsetTop, listener);
980            mProgress.showArrow(false);
981        }
982    }
983
984    @Override
985    public boolean onTouchEvent(MotionEvent ev) {
986        final int action = ev.getActionMasked();
987        int pointerIndex = -1;
988
989        if (mReturningToStart && action == MotionEvent.ACTION_DOWN) {
990            mReturningToStart = false;
991        }
992
993        if (!isEnabled() || mReturningToStart || canChildScrollUp()
994                || mRefreshing || mNestedScrollInProgress) {
995            // Fail fast if we're not in a state where a swipe is possible
996            return false;
997        }
998
999        switch (action) {
1000            case MotionEvent.ACTION_DOWN:
1001                mActivePointerId = ev.getPointerId(0);
1002                mIsBeingDragged = false;
1003                break;
1004
1005            case MotionEvent.ACTION_MOVE: {
1006                pointerIndex = ev.findPointerIndex(mActivePointerId);
1007                if (pointerIndex < 0) {
1008                    Log.e(LOG_TAG, "Got ACTION_MOVE event but have an invalid active pointer id.");
1009                    return false;
1010                }
1011
1012                final float y = ev.getY(pointerIndex);
1013                startDragging(y);
1014
1015                if (mIsBeingDragged) {
1016                    final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE;
1017                    if (overscrollTop > 0) {
1018                        moveSpinner(overscrollTop);
1019                    } else {
1020                        return false;
1021                    }
1022                }
1023                break;
1024            }
1025            case MotionEvent.ACTION_POINTER_DOWN: {
1026                pointerIndex = ev.getActionIndex();
1027                if (pointerIndex < 0) {
1028                    Log.e(LOG_TAG,
1029                            "Got ACTION_POINTER_DOWN event but have an invalid action index.");
1030                    return false;
1031                }
1032                mActivePointerId = ev.getPointerId(pointerIndex);
1033                break;
1034            }
1035
1036            case MotionEvent.ACTION_POINTER_UP:
1037                onSecondaryPointerUp(ev);
1038                break;
1039
1040            case MotionEvent.ACTION_UP: {
1041                pointerIndex = ev.findPointerIndex(mActivePointerId);
1042                if (pointerIndex < 0) {
1043                    Log.e(LOG_TAG, "Got ACTION_UP event but don't have an active pointer id.");
1044                    return false;
1045                }
1046
1047                if (mIsBeingDragged) {
1048                    final float y = ev.getY(pointerIndex);
1049                    final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE;
1050                    mIsBeingDragged = false;
1051                    finishSpinner(overscrollTop);
1052                }
1053                mActivePointerId = INVALID_POINTER;
1054                return false;
1055            }
1056            case MotionEvent.ACTION_CANCEL:
1057                return false;
1058        }
1059
1060        return true;
1061    }
1062
1063    @SuppressLint("NewApi")
1064    private void startDragging(float y) {
1065        final float yDiff = y - mInitialDownY;
1066        if (yDiff > mTouchSlop && !mIsBeingDragged) {
1067            mInitialMotionY = mInitialDownY + mTouchSlop;
1068            mIsBeingDragged = true;
1069            mProgress.setAlpha(STARTING_PROGRESS_ALPHA);
1070        }
1071    }
1072
1073    private void animateOffsetToCorrectPosition(int from, AnimationListener listener) {
1074        mFrom = from;
1075        mAnimateToCorrectPosition.reset();
1076        mAnimateToCorrectPosition.setDuration(ANIMATE_TO_TRIGGER_DURATION);
1077        mAnimateToCorrectPosition.setInterpolator(mDecelerateInterpolator);
1078        if (listener != null) {
1079            mCircleView.setAnimationListener(listener);
1080        }
1081        mCircleView.clearAnimation();
1082        mCircleView.startAnimation(mAnimateToCorrectPosition);
1083    }
1084
1085    private void animateOffsetToStartPosition(int from, AnimationListener listener) {
1086        if (mScale) {
1087            // Scale the item back down
1088            startScaleDownReturnToStartAnimation(from, listener);
1089        } else {
1090            mFrom = from;
1091            mAnimateToStartPosition.reset();
1092            mAnimateToStartPosition.setDuration(ANIMATE_TO_START_DURATION);
1093            mAnimateToStartPosition.setInterpolator(mDecelerateInterpolator);
1094            if (listener != null) {
1095                mCircleView.setAnimationListener(listener);
1096            }
1097            mCircleView.clearAnimation();
1098            mCircleView.startAnimation(mAnimateToStartPosition);
1099        }
1100    }
1101
1102    private final Animation mAnimateToCorrectPosition = new Animation() {
1103        @Override
1104        public void applyTransformation(float interpolatedTime, Transformation t) {
1105            int targetTop = 0;
1106            int endTarget = 0;
1107            if (!mUsingCustomStart) {
1108                endTarget = mSpinnerOffsetEnd - Math.abs(mOriginalOffsetTop);
1109            } else {
1110                endTarget = mSpinnerOffsetEnd;
1111            }
1112            targetTop = (mFrom + (int) ((endTarget - mFrom) * interpolatedTime));
1113            int offset = targetTop - mCircleView.getTop();
1114            setTargetOffsetTopAndBottom(offset);
1115            mProgress.setArrowScale(1 - interpolatedTime);
1116        }
1117    };
1118
1119    void moveToStart(float interpolatedTime) {
1120        int targetTop = 0;
1121        targetTop = (mFrom + (int) ((mOriginalOffsetTop - mFrom) * interpolatedTime));
1122        int offset = targetTop - mCircleView.getTop();
1123        setTargetOffsetTopAndBottom(offset);
1124    }
1125
1126    private final Animation mAnimateToStartPosition = new Animation() {
1127        @Override
1128        public void applyTransformation(float interpolatedTime, Transformation t) {
1129            moveToStart(interpolatedTime);
1130        }
1131    };
1132
1133    @SuppressLint("NewApi")
1134    private void startScaleDownReturnToStartAnimation(int from,
1135            Animation.AnimationListener listener) {
1136        mFrom = from;
1137        mStartingScale = mCircleView.getScaleX();
1138        mScaleDownToStartAnimation = new Animation() {
1139            @Override
1140            public void applyTransformation(float interpolatedTime, Transformation t) {
1141                float targetScale = (mStartingScale + (-mStartingScale  * interpolatedTime));
1142                setAnimationProgress(targetScale);
1143                moveToStart(interpolatedTime);
1144            }
1145        };
1146        mScaleDownToStartAnimation.setDuration(SCALE_DOWN_DURATION);
1147        if (listener != null) {
1148            mCircleView.setAnimationListener(listener);
1149        }
1150        mCircleView.clearAnimation();
1151        mCircleView.startAnimation(mScaleDownToStartAnimation);
1152    }
1153
1154    void setTargetOffsetTopAndBottom(int offset) {
1155        mCircleView.bringToFront();
1156        ViewCompat.offsetTopAndBottom(mCircleView, offset);
1157        mCurrentTargetOffsetTop = mCircleView.getTop();
1158    }
1159
1160    private void onSecondaryPointerUp(MotionEvent ev) {
1161        final int pointerIndex = ev.getActionIndex();
1162        final int pointerId = ev.getPointerId(pointerIndex);
1163        if (pointerId == mActivePointerId) {
1164            // This was our active pointer going up. Choose a new
1165            // active pointer and adjust accordingly.
1166            final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
1167            mActivePointerId = ev.getPointerId(newPointerIndex);
1168        }
1169    }
1170
1171    /**
1172     * Classes that wish to be notified when the swipe gesture correctly
1173     * triggers a refresh should implement this interface.
1174     */
1175    public interface OnRefreshListener {
1176        /**
1177         * Called when a swipe gesture triggers a refresh.
1178         */
1179        void onRefresh();
1180    }
1181
1182    /**
1183     * Classes that wish to override {@link SwipeRefreshLayout#canChildScrollUp()} method
1184     * behavior should implement this interface.
1185     */
1186    public interface OnChildScrollUpCallback {
1187        /**
1188         * Callback that will be called when {@link SwipeRefreshLayout#canChildScrollUp()} method
1189         * is called to allow the implementer to override its behavior.
1190         *
1191         * @param parent SwipeRefreshLayout that this callback is overriding.
1192         * @param child The child view of SwipeRefreshLayout.
1193         *
1194         * @return Whether it is possible for the child view of parent layout to scroll up.
1195         */
1196        boolean canChildScrollUp(SwipeRefreshLayout parent, @Nullable View child);
1197    }
1198}
1199