SwipeRefreshLayout.java revision c39bdbe9d24491caf92194229393ff58c16aed7f
1/*
2 * Copyright (C) 2013 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package android.support.v4.widget;
18
19import android.content.Context;
20import android.content.res.Resources;
21import android.content.res.TypedArray;
22import android.support.v4.view.MotionEventCompat;
23import android.support.v4.view.ViewCompat;
24import android.util.AttributeSet;
25import android.util.DisplayMetrics;
26import android.util.Log;
27import android.view.MotionEvent;
28import android.view.View;
29import android.view.ViewConfiguration;
30import android.view.ViewGroup;
31import android.view.animation.Animation;
32import android.view.animation.Animation.AnimationListener;
33import android.view.animation.DecelerateInterpolator;
34import android.view.animation.Transformation;
35import android.widget.AbsListView;
36
37/**
38 * The SwipeRefreshLayout should be used whenever the user can refresh the
39 * contents of a view via a vertical swipe gesture. The activity that
40 * instantiates this view should add an OnRefreshListener to be notified
41 * whenever the swipe to refresh gesture is completed. The SwipeRefreshLayout
42 * will notify the listener each and every time the gesture is completed again;
43 * the listener is responsible for correctly determining when to actually
44 * initiate a refresh of its content. If the listener determines there should
45 * not be a refresh, it must call setRefreshing(false) to cancel any visual
46 * indication of a refresh. If an activity wishes to show just the progress
47 * animation, it should call setRefreshing(true). To disable the gesture and
48 * progress animation, call setEnabled(false) on the view.
49 * <p>
50 * This layout should be made the parent of the view that will be refreshed as a
51 * result of the gesture and can only support one direct child. This view will
52 * also be made the target of the gesture and will be forced to match both the
53 * width and the height supplied in this layout. The SwipeRefreshLayout does not
54 * provide accessibility events; instead, a menu item must be provided to allow
55 * refresh of the content wherever this gesture is used.
56 * </p>
57 */
58public class SwipeRefreshLayout extends ViewGroup {
59    // Maps to ProgressBar.Large style
60    public static final int LARGE = MaterialProgressDrawable.LARGE;
61    // Maps to ProgressBar default style
62    public static final int DEFAULT = MaterialProgressDrawable.DEFAULT;
63
64    private static final String LOG_TAG = SwipeRefreshLayout.class.getSimpleName();
65
66    private static final int MAX_ALPHA = 255;
67    private static final int STARTING_PROGRESS_ALPHA = (int) (.3f * MAX_ALPHA);
68
69    private static final int CIRCLE_DIAMETER = 40;
70    private static final int CIRCLE_DIAMETER_LARGE = 56;
71
72    private static final float DECELERATE_INTERPOLATION_FACTOR = 2f;
73    private static final int INVALID_POINTER = -1;
74    private static final float DRAG_RATE = .5f;
75
76    // Max amount of circle that can be filled by progress during swipe gesture,
77    // where 1.0 is a full circle
78    private static final float MAX_PROGRESS_ANGLE = .8f;
79
80    private static final int SCALE_DOWN_DURATION = 150;
81
82    private static final int ALPHA_ANIMATION_DURATION = 300;
83
84    private static final int ANIMATE_TO_TRIGGER_DURATION = 200;
85
86    private static final int ANIMATE_TO_START_DURATION = 200;
87
88    // Default background for the progress spinner
89    private static final int CIRCLE_BG_LIGHT = 0xFFFAFAFA;
90    // Default offset in dips from the top of the view to where the progress spinner should stop
91    private static final int DEFAULT_CIRCLE_TARGET = 64;
92
93    private View mTarget; // the target of the gesture
94    private OnRefreshListener mListener;
95    private boolean mRefreshing = false;
96    private int mTouchSlop;
97    private float mTotalDragDistance = -1;
98    private int mMediumAnimationDuration;
99    private int mCurrentTargetOffsetTop;
100
101    private float mInitialMotionY;
102    private boolean mIsBeingDragged;
103    private int mActivePointerId = INVALID_POINTER;
104    // Whether this item is scaled up rather than clipped
105    private boolean mScale;
106
107    // Target is returning to its start offset because it was cancelled or a
108    // refresh was triggered.
109    private boolean mReturningToStart;
110    private final DecelerateInterpolator mDecelerateInterpolator;
111    private static final int[] LAYOUT_ATTRS = new int[] {
112        android.R.attr.enabled
113    };
114
115    private CircleImageView mCircleView;
116
117    protected int mFrom;
118
119    protected int mOriginalOffsetTop;
120
121    private MaterialProgressDrawable mProgress;
122
123    private Animation mScaleAnimation;
124
125    private Animation mScaleDownAnimation;
126
127    private Animation mAlphaStartAnimation;
128
129    private Animation mAlphaMaxAnimation;
130
131    private float mSpinnerFinalOffset;
132
133    private boolean mNotify;
134
135    private int mCircleWidth;
136
137    private int mCircleHeight;
138
139    private Animation.AnimationListener mRefreshListener = new Animation.AnimationListener() {
140        @Override
141        public void onAnimationStart(Animation animation) {
142        }
143
144        @Override
145        public void onAnimationRepeat(Animation animation) {
146        }
147
148        @Override
149        public void onAnimationEnd(Animation animation) {
150            if (mRefreshing) {
151                // Make sure the progress view is fully visible
152                mProgress.setAlpha(MAX_ALPHA);
153                mProgress.start();
154                if (mNotify) {
155                    if (mListener != null) {
156                        mListener.onRefresh();
157                    }
158                }
159            } else {
160                mProgress.stop();
161                mCircleView.setVisibility(View.GONE);
162                // Return the circle to its start position
163                if (mScale) {
164                    setAnimationProgress(0 /* animation complete and view is hidden */);
165                } else {
166                    setAnimationProgress(1 /* animation complete and view is showing */);
167                    setTargetOffsetTopAndBottom(-mCircleHeight - mCurrentTargetOffsetTop,
168                            true /* requires update */);
169                    mCircleView.setVisibility(View.VISIBLE);
170                }
171            }
172            mCurrentTargetOffsetTop = mCircleView.getTop();
173        }
174    };
175
176    private void setColorViewAlpha(int targetAlpha) {
177        mCircleView.getBackground().setAlpha(targetAlpha);
178        mProgress.setAlpha(targetAlpha);
179    }
180
181    /**
182     * The refresh indicator starting and resting position is always positioned
183     * near the top of the refreshing content. This position is a consistent
184     * location, but can be adjusted in either direction based on whether or not
185     * there is a toolbar or actionbar present.
186     *
187     * @param scale Set to true if there is no view at a higher z-order than
188     *            where the progress spinner is set to appear.
189     * @param start The offset in pixels from the top of this view at which the
190     *            progress spinner should appear.
191     * @param end The offset in pixels from the top of this view at which the
192     *            progress spinner should come to rest after a successful swipe
193     *            gesture.
194     */
195    public void setProgressViewOffset(boolean scale, int start, int end) {
196        mScale = scale;
197        mCircleView.setVisibility(View.GONE);
198        mOriginalOffsetTop = mCurrentTargetOffsetTop = start;
199        mSpinnerFinalOffset = end;
200        mCircleView.invalidate();
201    }
202
203    /**
204     * The refresh indicator resting position is always positioned near the top
205     * of the refreshing content. This position is a consistent location, but
206     * can be adjusted in either direction based on whether or not there is a
207     * toolbar or actionbar present.
208     *
209     * @param scale Set to true if there is no view at a higher z-order than
210     *            where the progress spinner is set to appear.
211     * @param end The offset in pixels from the top of this view at which the
212     *            progress spinner should come to rest after a successful swipe
213     *            gesture.
214     */
215    public void setProgressViewEndTarget(boolean scale, int end) {
216        mSpinnerFinalOffset = end;
217        mScale = scale;
218        mCircleView.invalidate();
219    }
220
221    /**
222     * One of DEFAULT, or LARGE.
223     */
224    public void setSize(int size) {
225        if (size != MaterialProgressDrawable.LARGE && size != MaterialProgressDrawable.DEFAULT) {
226            return;
227        }
228        final DisplayMetrics metrics = getResources().getDisplayMetrics();
229        if (size == MaterialProgressDrawable.LARGE) {
230            mCircleHeight = mCircleWidth = (int) (CIRCLE_DIAMETER_LARGE * metrics.density);
231        } else {
232            mCircleHeight = mCircleWidth = (int) (CIRCLE_DIAMETER * metrics.density);
233        }
234        // force the bounds of the progress circle inside the circle view to
235        // update by setting it to null before updating its size and then
236        // re-setting it
237        mCircleView.setImageDrawable(null);
238        mProgress.updateSizes(size);
239        mCircleView.setImageDrawable(mProgress);
240    }
241
242    /**
243     * Simple constructor to use when creating a SwipeRefreshLayout from code.
244     *
245     * @param context
246     */
247    public SwipeRefreshLayout(Context context) {
248        this(context, null);
249    }
250
251    /**
252     * Constructor that is called when inflating SwipeRefreshLayout from XML.
253     *
254     * @param context
255     * @param attrs
256     */
257    public SwipeRefreshLayout(Context context, AttributeSet attrs) {
258        super(context, attrs);
259
260        mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
261
262        mMediumAnimationDuration = getResources().getInteger(
263                android.R.integer.config_mediumAnimTime);
264
265        setWillNotDraw(false);
266        mDecelerateInterpolator = new DecelerateInterpolator(DECELERATE_INTERPOLATION_FACTOR);
267
268        final TypedArray a = context.obtainStyledAttributes(attrs, LAYOUT_ATTRS);
269        setEnabled(a.getBoolean(0, true));
270        a.recycle();
271
272        final DisplayMetrics metrics = getResources().getDisplayMetrics();
273        mCircleWidth = (int) (CIRCLE_DIAMETER * metrics.density);
274        mCircleHeight = (int) (CIRCLE_DIAMETER * metrics.density);
275
276        createProgressView();
277        ViewCompat.setChildrenDrawingOrderEnabled(this, true);
278        // the absolute offset has to take into account that the circle starts at an offset
279        mSpinnerFinalOffset = DEFAULT_CIRCLE_TARGET * metrics.density;
280        mTotalDragDistance = mSpinnerFinalOffset;
281    }
282
283    protected int getChildDrawingOrder (int childCount, int i) {
284        if (getChildAt(i).equals(mCircleView)) {
285            return childCount - 1;
286        }
287        return i;
288    }
289
290    private void createProgressView() {
291        mCircleView = new CircleImageView(getContext(), CIRCLE_BG_LIGHT, CIRCLE_DIAMETER/2);
292        mProgress = new MaterialProgressDrawable(getContext(), this);
293        mProgress.setBackgroundColor(CIRCLE_BG_LIGHT);
294        mCircleView.setImageDrawable(mProgress);
295        addView(mCircleView);
296        mCurrentTargetOffsetTop = mOriginalOffsetTop = -mCircleHeight;
297    }
298
299    /**
300     * Set the listener to be notified when a refresh is triggered via the swipe
301     * gesture.
302     */
303    public void setOnRefreshListener(OnRefreshListener listener) {
304        mListener = listener;
305    }
306
307    /**
308     * Pre API 11, alpha is used to make the progress circle appear instead of scale.
309     */
310    private boolean isAlphaUsedForScale() {
311        return android.os.Build.VERSION.SDK_INT < 11;
312    }
313
314    /**
315     * Notify the widget that refresh state has changed. Do not call this when
316     * refresh is triggered by a swipe gesture.
317     *
318     * @param refreshing Whether or not the view should show refresh progress.
319     */
320    public void setRefreshing(boolean refreshing) {
321        if (refreshing && mRefreshing != refreshing) {
322            // scale and show
323            mRefreshing = refreshing;
324            setTargetOffsetTopAndBottom(
325                    (int) ((mSpinnerFinalOffset + mOriginalOffsetTop) - mCurrentTargetOffsetTop),
326                    true /* requires update */);
327            mNotify = false;
328            startScaleUpAnimation(mRefreshListener);
329        } else {
330            setRefreshing(refreshing, false /* notify */);
331        }
332    }
333
334    private void startScaleUpAnimation(AnimationListener listener) {
335        mCircleView.setVisibility(View.VISIBLE);
336        if (android.os.Build.VERSION.SDK_INT >= 11) {
337            // Pre API 11, alpha is used in place of scale up to show the
338            // progress circle appearing.
339            // Don't adjust the alpha during appearance otherwise.
340            mProgress.setAlpha(MAX_ALPHA);
341        }
342        mScaleAnimation = new Animation() {
343            @Override
344            public void applyTransformation(float interpolatedTime, Transformation t) {
345                setAnimationProgress(interpolatedTime);
346            }
347        };
348        mScaleAnimation.setDuration(mMediumAnimationDuration);
349        if (listener != null) {
350            mCircleView.setAnimationListener(listener);
351        }
352        mCircleView.clearAnimation();
353        mCircleView.startAnimation(mScaleAnimation);
354    }
355
356    /**
357     * Pre API 11, this does an alpha animation.
358     * @param progress
359     */
360    private void setAnimationProgress(float progress) {
361        if (isAlphaUsedForScale()) {
362            setColorViewAlpha((int) (progress * MAX_ALPHA));
363        } else {
364            ViewCompat.setScaleX(mCircleView, progress);
365            ViewCompat.setScaleY(mCircleView, progress);
366        }
367    }
368
369    private void setRefreshing(boolean refreshing, final boolean notify) {
370        if (mRefreshing != refreshing) {
371            mNotify = notify;
372            ensureTarget();
373            mRefreshing = refreshing;
374            if (mRefreshing) {
375                animateOffsetToCorrectPosition(mCurrentTargetOffsetTop, mRefreshListener);
376            } else {
377                startScaleDownAnimation(mRefreshListener);
378            }
379        }
380    }
381
382    private void startScaleDownAnimation(Animation.AnimationListener listener) {
383        mScaleDownAnimation = new Animation() {
384            @Override
385            public void applyTransformation(float interpolatedTime, Transformation t) {
386                setAnimationProgress(1 - interpolatedTime);
387            }
388        };
389        mScaleDownAnimation.setDuration(SCALE_DOWN_DURATION);
390        if (listener != null) {
391            mCircleView.setAnimationListener(listener);
392        }
393        mCircleView.clearAnimation();
394        mCircleView.startAnimation(mScaleDownAnimation);
395    }
396
397    private void startProgressAlphaStartAnimation() {
398        mAlphaStartAnimation = startAlphaAnimation(mProgress.getAlpha(), STARTING_PROGRESS_ALPHA);
399    }
400
401    private void startProgressAlphaMaxAnimation() {
402        mAlphaMaxAnimation = startAlphaAnimation(mProgress.getAlpha(), MAX_ALPHA);
403    }
404
405    private Animation startAlphaAnimation(final int startingAlpha, final int endingAlpha) {
406        // Pre API 11, alpha is used in place of scale. Don't also use it to
407        // show the trigger point.
408        if (mScale && isAlphaUsedForScale()) {
409            return null;
410        }
411        Animation alpha = new Animation() {
412            @Override
413            public void applyTransformation(float interpolatedTime, Transformation t) {
414                mProgress
415                        .setAlpha((int) (startingAlpha+ ((endingAlpha - startingAlpha)
416                                * interpolatedTime)));
417            }
418        };
419        alpha.setDuration(ALPHA_ANIMATION_DURATION);
420        // Clear out the previous animation listeners.
421        mCircleView.setAnimationListener(null);
422        mCircleView.clearAnimation();
423        mCircleView.startAnimation(alpha);
424        return alpha;
425    }
426
427    /**
428     * Set the background color of the progress spinner disc.
429     *
430     * @param colorRes Resource id of the color.
431     */
432    public void setProgressBackgroundColor(int colorRes) {
433        mCircleView.setBackgroundColor(colorRes);
434        mProgress.setBackgroundColor(getResources().getColor(colorRes));
435    }
436
437    /**
438     * @deprecated Use {@link #setColorSchemeResources(int...)}
439     */
440    @Deprecated
441    public void setColorScheme(int... colors) {
442        setColorSchemeResources(colors);
443    }
444
445    /**
446     * Set the color resources used in the progress animation from color resources.
447     * The first color will also be the color of the bar that grows in response
448     * to a user swipe gesture.
449     *
450     * @param colorResIds
451     */
452    public void setColorSchemeResources(int... colorResIds) {
453        final Resources res = getResources();
454        int[] colorRes = new int[colorResIds.length];
455        for (int i = 0; i < colorResIds.length; i++) {
456            colorRes[i] = res.getColor(colorResIds[i]);
457        }
458        setColorSchemeColors(colorRes);
459    }
460
461    /**
462     * Set the colors used in the progress animation. The first
463     * color will also be the color of the bar that grows in response to a user
464     * swipe gesture.
465     *
466     * @param colors
467     */
468    public void setColorSchemeColors(int... colors) {
469        ensureTarget();
470        mProgress.setColorSchemeColors(colors);
471    }
472
473    /**
474     * @return Whether the SwipeRefreshWidget is actively showing refresh
475     *         progress.
476     */
477    public boolean isRefreshing() {
478        return mRefreshing;
479    }
480
481    private void ensureTarget() {
482        // Don't bother getting the parent height if the parent hasn't been laid
483        // out yet.
484        if (mTarget == null) {
485            for (int i = 0; i < getChildCount(); i++) {
486                View child = getChildAt(i);
487                if (!child.equals(mCircleView)) {
488                    mTarget = child;
489                    break;
490                }
491            }
492        }
493    }
494
495    /**
496     * Set the distance to trigger a sync in dips
497     *
498     * @param distance
499     */
500    public void setDistanceToTriggerSync(int distance) {
501        mTotalDragDistance = distance;
502    }
503
504    @Override
505    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
506        final int width = getMeasuredWidth();
507        final int height = getMeasuredHeight();
508        if (getChildCount() == 0) {
509            return;
510        }
511        if (mTarget == null) {
512            ensureTarget();
513        }
514        if (mTarget == null) {
515            return;
516        }
517        final View child = mTarget;
518        final int childLeft = getPaddingLeft();
519        final int childTop = getPaddingTop();
520        final int childWidth = width - getPaddingLeft() - getPaddingRight();
521        final int childHeight = height - getPaddingTop() - getPaddingBottom();
522        child.layout(childLeft, childTop, childLeft + childWidth, childTop + childHeight);
523        int circleWidth = mCircleView.getMeasuredWidth();
524        int circleHeight = mCircleView.getMeasuredHeight();
525        mCircleView.layout((width / 2 - circleWidth / 2), mCurrentTargetOffsetTop,
526                (width / 2 + circleWidth / 2), mCurrentTargetOffsetTop + circleHeight);
527    }
528
529    @Override
530    public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
531        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
532        if (mTarget == null) {
533            ensureTarget();
534        }
535        if (mTarget == null) {
536            return;
537        }
538        mTarget.measure(MeasureSpec.makeMeasureSpec(
539                getMeasuredWidth() - getPaddingLeft() - getPaddingRight(),
540                MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(
541                getMeasuredHeight() - getPaddingTop() - getPaddingBottom(), MeasureSpec.EXACTLY));
542        mCircleView.measure(MeasureSpec.makeMeasureSpec(mCircleWidth, MeasureSpec.EXACTLY),
543                MeasureSpec.makeMeasureSpec(mCircleHeight, MeasureSpec.EXACTLY));
544    }
545
546    /**
547     * @return Whether it is possible for the child view of this layout to
548     *         scroll up. Override this if the child view is a custom view.
549     */
550    public boolean canChildScrollUp() {
551        if (android.os.Build.VERSION.SDK_INT < 14) {
552            if (mTarget instanceof AbsListView) {
553                final AbsListView absListView = (AbsListView) mTarget;
554                return absListView.getChildCount() > 0
555                        && (absListView.getFirstVisiblePosition() > 0 || absListView.getChildAt(0)
556                                .getTop() < absListView.getPaddingTop());
557            } else {
558                return mTarget.getScrollY() > 0;
559            }
560        } else {
561            return ViewCompat.canScrollVertically(mTarget, -1);
562        }
563    }
564
565    @Override
566    public boolean onInterceptTouchEvent(MotionEvent ev) {
567        ensureTarget();
568
569        final int action = MotionEventCompat.getActionMasked(ev);
570
571        if (mReturningToStart && action == MotionEvent.ACTION_DOWN) {
572            mReturningToStart = false;
573        }
574
575        if (!isEnabled() || mReturningToStart || canChildScrollUp() || mRefreshing) {
576            // Fail fast if we're not in a state where a swipe is possible
577            return false;
578        }
579
580        switch (action) {
581            case MotionEvent.ACTION_DOWN:
582                setTargetOffsetTopAndBottom(mOriginalOffsetTop - mCircleView.getTop(), true);
583                mActivePointerId = MotionEventCompat.getPointerId(ev, 0);
584                mIsBeingDragged = false;
585                final float initialMotionY = getMotionEventY(ev, mActivePointerId);
586                if (initialMotionY == -1) {
587                    return false;
588                }
589                mInitialMotionY = initialMotionY;
590
591            case MotionEvent.ACTION_MOVE:
592                if (mActivePointerId == INVALID_POINTER) {
593                    Log.e(LOG_TAG, "Got ACTION_MOVE event but don't have an active pointer id.");
594                    return false;
595                }
596
597                final float y = getMotionEventY(ev, mActivePointerId);
598                if (y == -1) {
599                    return false;
600                }
601                final float yDiff = y - mInitialMotionY;
602                if (yDiff > mTouchSlop && !mIsBeingDragged) {
603                    mIsBeingDragged = true;
604                    mProgress.setAlpha(STARTING_PROGRESS_ALPHA);
605                }
606                break;
607
608            case MotionEventCompat.ACTION_POINTER_UP:
609                onSecondaryPointerUp(ev);
610                break;
611
612            case MotionEvent.ACTION_UP:
613            case MotionEvent.ACTION_CANCEL:
614                mIsBeingDragged = false;
615                mActivePointerId = INVALID_POINTER;
616                break;
617        }
618
619        return mIsBeingDragged;
620    }
621
622    private float getMotionEventY(MotionEvent ev, int activePointerId) {
623        final int index = MotionEventCompat.findPointerIndex(ev, activePointerId);
624        if (index < 0) {
625            return -1;
626        }
627        return MotionEventCompat.getY(ev, index);
628    }
629
630    @Override
631    public void requestDisallowInterceptTouchEvent(boolean b) {
632        // Nope.
633    }
634
635    private boolean isAnimationRunning(Animation animation) {
636        return animation != null && animation.hasStarted() && !animation.hasEnded();
637    }
638
639    @Override
640    public boolean onTouchEvent(MotionEvent ev) {
641        final int action = MotionEventCompat.getActionMasked(ev);
642
643        if (mReturningToStart && action == MotionEvent.ACTION_DOWN) {
644            mReturningToStart = false;
645        }
646
647        if (!isEnabled() || mReturningToStart || canChildScrollUp()) {
648            // Fail fast if we're not in a state where a swipe is possible
649            return false;
650        }
651
652        switch (action) {
653            case MotionEvent.ACTION_DOWN:
654                mActivePointerId = MotionEventCompat.getPointerId(ev, 0);
655                mIsBeingDragged = false;
656                break;
657
658            case MotionEvent.ACTION_MOVE: {
659                final int pointerIndex = MotionEventCompat.findPointerIndex(ev, mActivePointerId);
660                if (pointerIndex < 0) {
661                    Log.e(LOG_TAG, "Got ACTION_MOVE event but have an invalid active pointer id.");
662                    return false;
663                }
664
665                final float y = MotionEventCompat.getY(ev, pointerIndex);
666                final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE;
667                if (mIsBeingDragged) {
668                    mProgress.showArrow(true);
669                    float originalDragPercent = overscrollTop / mTotalDragDistance;
670                    if (originalDragPercent < 0) {
671                        return false;
672                    }
673                    float dragPercent = Math.min(1f, Math.abs(originalDragPercent));
674                    float adjustedPercent = (float) Math.max(dragPercent - .4, 0) * 5 / 3;
675                    float extraOS = Math.abs(overscrollTop) - mTotalDragDistance;
676                    float slingshotDist = mSpinnerFinalOffset;
677                    float tensionSlingshotPercent = Math.max(0,
678                            Math.min(extraOS, slingshotDist * 2) / slingshotDist);
679                    float tensionPercent = (float) ((tensionSlingshotPercent / 4) - Math.pow(
680                            (tensionSlingshotPercent / 4), 2)) * 2f;
681                    float extraMove = (slingshotDist) * tensionPercent * 2;
682
683                    int targetY = mOriginalOffsetTop
684                            + (int) ((slingshotDist * dragPercent) + extraMove);
685                    // where 1.0f is a full circle
686                    if (mCircleView.getVisibility() != View.VISIBLE) {
687                        mCircleView.setVisibility(View.VISIBLE);
688                    }
689                    if (overscrollTop < mTotalDragDistance) {
690                        if (mScale) {
691                            setAnimationProgress(overscrollTop / mTotalDragDistance);
692                        }
693                        if (mProgress.getAlpha() > STARTING_PROGRESS_ALPHA
694                                && !isAnimationRunning(mAlphaStartAnimation)) {
695                            // Animate the alpha
696                            startProgressAlphaStartAnimation();
697                        }
698                        float strokeStart = (float) (adjustedPercent * .8f);
699                        mProgress.setStartEndTrim(0f, Math.min(MAX_PROGRESS_ANGLE, strokeStart));
700                        mProgress.setArrowScale(Math.min(1f, adjustedPercent));
701                    } else {
702                        if (mProgress.getAlpha() < MAX_ALPHA
703                                && !isAnimationRunning(mAlphaMaxAnimation)) {
704                            // Animate the alpha
705                            startProgressAlphaMaxAnimation();
706                        }
707                    }
708                    float rotation = (-0.25f + .4f * adjustedPercent + tensionPercent * 2) * .5f;
709                    mProgress.setProgressRotation(rotation);
710                    setTargetOffsetTopAndBottom(targetY - mCurrentTargetOffsetTop,
711                            true /* requires update */);
712                }
713                break;
714            }
715            case MotionEventCompat.ACTION_POINTER_DOWN: {
716                final int index = MotionEventCompat.getActionIndex(ev);
717                mActivePointerId = MotionEventCompat.getPointerId(ev, index);
718                break;
719            }
720
721            case MotionEventCompat.ACTION_POINTER_UP:
722                onSecondaryPointerUp(ev);
723                break;
724
725            case MotionEvent.ACTION_UP:
726            case MotionEvent.ACTION_CANCEL: {
727                final int pointerIndex = MotionEventCompat.findPointerIndex(ev, mActivePointerId);
728                final float y = MotionEventCompat.getY(ev, pointerIndex);
729                final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE;
730                mIsBeingDragged = false;
731                if (overscrollTop > mTotalDragDistance) {
732                    setRefreshing(true, true /* notify */);
733                } else {
734                    // cancel refresh
735                    mRefreshing = false;
736                    mProgress.setStartEndTrim(0f, 0f);
737                    animateOffsetToStartPosition(mCurrentTargetOffsetTop, null);
738                    mProgress.showArrow(false);
739                }
740                mActivePointerId = INVALID_POINTER;
741                return false;
742            }
743        }
744
745        return true;
746    }
747
748    private void animateOffsetToCorrectPosition(int from, AnimationListener listener) {
749        mFrom = from;
750        mAnimateToCorrectPosition.reset();
751        mAnimateToCorrectPosition.setDuration(ANIMATE_TO_TRIGGER_DURATION);
752        mAnimateToCorrectPosition.setInterpolator(mDecelerateInterpolator);
753        if (listener != null) {
754            mCircleView.setAnimationListener(listener);
755        }
756        mCircleView.clearAnimation();
757        mCircleView.startAnimation(mAnimateToCorrectPosition);
758    }
759
760    private void animateOffsetToStartPosition(int from, AnimationListener listener) {
761        mFrom = from;
762        mAnimateToStartPosition.reset();
763        mAnimateToStartPosition.setDuration(ANIMATE_TO_START_DURATION);
764        mAnimateToStartPosition.setInterpolator(mDecelerateInterpolator);
765        if (listener != null) {
766            mCircleView.setAnimationListener(listener);
767        }
768        mCircleView.clearAnimation();
769        mCircleView.startAnimation(mAnimateToStartPosition);
770    }
771
772    private final Animation mAnimateToCorrectPosition = new Animation() {
773        @Override
774        public void applyTransformation(float interpolatedTime, Transformation t) {
775            int targetTop = 0;
776            int endTarget = (int) (mSpinnerFinalOffset - Math.abs(mOriginalOffsetTop));
777            targetTop = (mFrom + (int) ((endTarget - mFrom) * interpolatedTime));
778            int offset = targetTop - mCircleView.getTop();
779            setTargetOffsetTopAndBottom(offset, false /* requires update */);
780        }
781    };
782
783    private final Animation mAnimateToStartPosition = new Animation() {
784        @Override
785        public void applyTransformation(float interpolatedTime, Transformation t) {
786            int targetTop = 0;
787            if (mFrom != mOriginalOffsetTop) {
788                targetTop = (mFrom + (int) ((mOriginalOffsetTop - mFrom) * interpolatedTime));
789            }
790            int offset = targetTop - mCircleView.getTop();
791            setTargetOffsetTopAndBottom(offset, false /* requires update */);
792        }
793    };
794
795    private void setTargetOffsetTopAndBottom(int offset, boolean requiresUpdate) {
796        mCircleView.bringToFront();
797        mCircleView.offsetTopAndBottom(offset);
798        mCurrentTargetOffsetTop = mCircleView.getTop();
799        if (requiresUpdate && android.os.Build.VERSION.SDK_INT < 11) {
800            invalidate();
801        }
802    }
803
804    private void onSecondaryPointerUp(MotionEvent ev) {
805        final int pointerIndex = MotionEventCompat.getActionIndex(ev);
806        final int pointerId = MotionEventCompat.getPointerId(ev, pointerIndex);
807        if (pointerId == mActivePointerId) {
808            // This was our active pointer going up. Choose a new
809            // active pointer and adjust accordingly.
810            final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
811            mActivePointerId = MotionEventCompat.getPointerId(ev, newPointerIndex);
812        }
813    }
814
815    /**
816     * Classes that wish to be notified when the swipe gesture correctly
817     * triggers a refresh should implement this interface.
818     */
819    public interface OnRefreshListener {
820        public void onRefresh();
821    }
822}
823