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