SwipeRefreshLayout.java revision 4ca82494575dc4927e7ff51291c3645025cc9414
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    // Maps to ProgressBar.Small style
64    public static final int SMALL = MaterialProgressDrawable.SMALL;
65
66    private static final String LOG_TAG = SwipeRefreshLayout.class.getSimpleName();
67
68    private static final int MAX_ALPHA = 255;
69
70    private static final int CIRCLE_DIAMETER = 40;
71    private static final int CIRCLE_DIAMETER_LARGE = 96;
72    private static final int CIRCLE_DIAMETER_SMALL = 16;
73
74    private static final float DECELERATE_INTERPOLATION_FACTOR = 2f;
75    private static final int INVALID_POINTER = -1;
76    private static final float DRAG_RATE = .5f;
77    private static final int MAX_OVERSWIPE = 80;
78
79    // Default background for the progress spinner
80    private static final int CIRCLE_BG_LIGHT = 0xFFFAFAFA;
81    private static final float MIN_CIRCLE_SCALE = .1f;
82    // Default offset in dips from the top of the view to where the progress spinner should stop
83    private static final int DEFAULT_CIRCLE_TARGET = 64;
84
85    private View mTarget; // the target of the gesture
86    private OnRefreshListener mListener;
87    private boolean mRefreshing = false;
88    private int mTouchSlop;
89    private float mDistanceToTriggerSync = -1;
90    private int mMediumAnimationDuration;
91    private int mShortAnimationDuration;
92    private int mCurrentTargetOffsetTop;
93
94    private float mInitialMotionY;
95    private boolean mIsBeingDragged;
96    private int mActivePointerId = INVALID_POINTER;
97    // Whether this item is scaled up rather than clipped
98    private boolean mScale;
99
100    // Target is returning to its start offset because it was cancelled or a
101    // refresh was triggered.
102    private boolean mReturningToStart;
103    private final DecelerateInterpolator mDecelerateInterpolator;
104    private static final int[] LAYOUT_ATTRS = new int[] {
105        android.R.attr.enabled
106    };
107
108    private CircleImageView mCircleView;
109
110    protected int mFrom;
111
112    protected int mOriginalOffsetTop;
113
114    private MaterialProgressDrawable mProgress;
115
116    private Animation mScaleAnimation;
117
118    private Animation mScaleDownAnimation;
119
120    private float mLastY;
121
122    private float mTargetCirclePosition;
123
124    private boolean mNotify;
125
126    private int mCircleWidth;
127
128    private int mCircleHeight;
129
130    private Animation.AnimationListener mRefreshListener = new Animation.AnimationListener() {
131        @Override
132        public void onAnimationStart(Animation animation) {
133        }
134
135        @Override
136        public void onAnimationRepeat(Animation animation) {
137        }
138
139        @Override
140        public void onAnimationEnd(Animation animation) {
141            if (mRefreshing) {
142                mProgress.showArrow(false);
143                mProgress.start();
144                if (mNotify) {
145                    if (mListener != null) {
146                        mListener.onRefresh();
147                    }
148                }
149            } else {
150                mProgress.stop();
151                mCircleView.setVisibility(View.GONE);
152                // Return the circle to its start position
153                if (mScale) {
154                    setAnimationProgress(0 /* animation complete and view is hidden */);
155                } else {
156                    setAnimationProgress(1 /* animation complete and view is showing */);
157                    setTargetOffsetTopAndBottom(-mCircleHeight - mCurrentTargetOffsetTop,
158                            true /* requires update */);
159                    mCircleView.setVisibility(View.VISIBLE);
160                }
161            }
162            mCurrentTargetOffsetTop = mCircleView.getTop();
163        }
164    };
165
166    private void setColorViewAlpha(int targetAlpha) {
167        mCircleView.getBackground().setAlpha(targetAlpha);
168        mProgress.setAlpha(targetAlpha);
169    }
170
171    /**
172     * The refresh indicator starting and resting position is always positioned
173     * near the top of the refreshing content. This position is a consistent
174     * location, but can be adjusted in either direction based on whether or not
175     * there is a toolbar or actionbar present.
176     *
177     * @param scale Set to true if there is no view at a higher z-order than
178     *            where the progress spinner is set to appear.
179     * @param start The offset in pixels from the top of this view at which the
180     *            progress spinner should appear.
181     * @param end The offset in pixels from the top of this view at which the
182     *            progress spinner should come to rest after a successful swipe
183     *            gesture.
184     */
185    public void setProgressViewOffset(boolean scale, int start, int end) {
186        mScale = scale;
187        mCircleView.setVisibility(View.GONE);
188        mOriginalOffsetTop = mCurrentTargetOffsetTop = start;
189        mDistanceToTriggerSync = (mTargetCirclePosition / DRAG_RATE);
190        mTargetCirclePosition = end;
191        mCircleView.invalidate();
192    }
193
194    /**
195     * One of SMALL, DEFAULT, or LARGE. DEFAULT is ProgressBar default styling.
196     */
197    public void setSize(int size) {
198        if (size != MaterialProgressDrawable.LARGE && size != MaterialProgressDrawable.DEFAULT
199                && size != MaterialProgressDrawable.SMALL) {
200            return;
201        }
202        final DisplayMetrics metrics = getResources().getDisplayMetrics();
203        if (size == MaterialProgressDrawable.LARGE) {
204            mCircleHeight = mCircleWidth = (int) (CIRCLE_DIAMETER_LARGE * metrics.density);
205        } else if (size == MaterialProgressDrawable.SMALL) {
206            mCircleHeight = mCircleWidth = (int) (CIRCLE_DIAMETER_SMALL * metrics.density);
207        } else {
208            mCircleHeight = mCircleWidth = (int) (CIRCLE_DIAMETER * metrics.density);
209        }
210        mProgress.updateSizes(size);
211    }
212
213    /**
214     * Simple constructor to use when creating a SwipeRefreshLayout from code.
215     *
216     * @param context
217     */
218    public SwipeRefreshLayout(Context context) {
219        this(context, null);
220    }
221
222    /**
223     * Constructor that is called when inflating SwipeRefreshLayout from XML.
224     *
225     * @param context
226     * @param attrs
227     */
228    public SwipeRefreshLayout(Context context, AttributeSet attrs) {
229        super(context, attrs);
230
231        mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
232
233        mMediumAnimationDuration = getResources().getInteger(
234                android.R.integer.config_mediumAnimTime);
235        mShortAnimationDuration = getResources().getInteger(
236                android.R.integer.config_shortAnimTime);
237
238        setWillNotDraw(false);
239        mDecelerateInterpolator = new DecelerateInterpolator(DECELERATE_INTERPOLATION_FACTOR);
240
241        final TypedArray a = context.obtainStyledAttributes(attrs, LAYOUT_ATTRS);
242        setEnabled(a.getBoolean(0, true));
243        a.recycle();
244
245        final DisplayMetrics metrics = getResources().getDisplayMetrics();
246        mCircleWidth = (int) (CIRCLE_DIAMETER * metrics.density);
247        mCircleHeight = (int) (CIRCLE_DIAMETER * metrics.density);
248
249        mTargetCirclePosition = DEFAULT_CIRCLE_TARGET * metrics.density;
250        // Since the circle moves at 1/2 rate of drag, and we want the circle to
251        // end at 64, and its starts -Diameter offscreen, it needs to move overall this
252        mDistanceToTriggerSync = (mTargetCirclePosition / DRAG_RATE)
253                + (mCircleWidth / DRAG_RATE);
254        createProgressView();
255        ViewCompat.setChildrenDrawingOrderEnabled(this, true);
256    }
257
258    protected int getChildDrawingOrder (int childCount, int i) {
259        if (getChildAt(i).equals(mCircleView)) {
260            return childCount - 1;
261        }
262        return i;
263    }
264
265    private void createProgressView() {
266        mCircleView = new CircleImageView(getContext(), CIRCLE_BG_LIGHT, CIRCLE_DIAMETER/2);
267        mProgress = new MaterialProgressDrawable(getContext(), this);
268        mCircleView.setImageDrawable(mProgress);
269        addView(mCircleView);
270        mCurrentTargetOffsetTop = mOriginalOffsetTop = -mCircleWidth;
271    }
272
273    /**
274     * Set the listener to be notified when a refresh is triggered via the swipe
275     * gesture.
276     */
277    public void setOnRefreshListener(OnRefreshListener listener) {
278        mListener = listener;
279    }
280
281    /**
282     * Notify the widget that refresh state has changed. Do not call this when
283     * refresh is triggered by a swipe gesture.
284     *
285     * @param refreshing Whether or not the view should show refresh progress.
286     */
287    public void setRefreshing(boolean refreshing) {
288        if (refreshing && mRefreshing != refreshing) {
289            // scale and show
290            mRefreshing = refreshing;
291            setTargetOffsetTopAndBottom((int) (mTargetCirclePosition - mCircleView.getTop()),
292                    true /* requires update */);
293            mNotify = false;
294            startScaleUpAnimation(mRefreshListener);
295        } else {
296            setRefreshing(refreshing, false /* notify */);
297        }
298    }
299
300    private void startScaleUpAnimation(AnimationListener listener) {
301        mCircleView.setVisibility(View.VISIBLE);
302        mScaleAnimation = new Animation() {
303            @Override
304            public void applyTransformation(float interpolatedTime, Transformation t) {
305                setAnimationProgress(interpolatedTime);
306            }
307        };
308        mScaleAnimation.setDuration(mMediumAnimationDuration);
309        if (listener != null) {
310            mCircleView.setAnimationListener(listener);
311        }
312        mCircleView.clearAnimation();
313        mCircleView.startAnimation(mScaleAnimation);
314    }
315
316    /**
317     * Pre API 11, this does an alpha animation.
318     * @param progress
319     */
320    private void setAnimationProgress(float progress) {
321        if (android.os.Build.VERSION.SDK_INT < 11) {
322            setColorViewAlpha((int) (progress * MAX_ALPHA));
323        } else {
324            ViewCompat.setScaleX(mCircleView, progress);
325            ViewCompat.setScaleY(mCircleView, progress);
326        }
327    }
328
329    private void setRefreshing(boolean refreshing, final boolean notify) {
330        if (mRefreshing != refreshing) {
331            mNotify = notify;
332            ensureTarget();
333            mRefreshing = refreshing;
334            if (mRefreshing) {
335                animateOffsetToCorrectPosition(mCurrentTargetOffsetTop, mRefreshListener);
336            } else {
337                startScaleDownAnimation(mRefreshListener);
338            }
339        }
340    }
341
342    private void startScaleDownAnimation(Animation.AnimationListener listener) {
343        mScaleDownAnimation = new Animation() {
344            @Override
345            public void applyTransformation(float interpolatedTime, Transformation t) {
346                setAnimationProgress(1 - interpolatedTime);
347            }
348        };
349        mScaleDownAnimation.setDuration(mShortAnimationDuration);
350        if (listener != null) {
351            mCircleView.setAnimationListener(listener);
352        }
353        mCircleView.clearAnimation();
354        mCircleView.startAnimation(mScaleDownAnimation);
355    }
356
357    public void setProgressBackgroundColor(int colorRes) {
358        mCircleView.setBackgroundColor(colorRes);
359    }
360
361    /**
362     * @deprecated Use {@link #setColorSchemeResources(int...)}
363     */
364    @Deprecated
365    public void setColorScheme(int... colors) {
366        setColorSchemeResources(colors);
367    }
368
369    /**
370     * Set the color resources used in the progress animation from color resources.
371     * The first color will also be the color of the bar that grows in response
372     * to a user swipe gesture.
373     *
374     * @param colorResIds
375     */
376    public void setColorSchemeResources(int... colorResIds) {
377        final Resources res = getResources();
378        int[] colorRes = new int[colorResIds.length];
379        for (int i = 0; i < colorResIds.length; i++) {
380            colorRes[i] = res.getColor(colorResIds[i]);
381        }
382        setColorSchemeColors(colorRes);
383    }
384
385    /**
386     * Set the colors used in the progress animation. The first
387     * color will also be the color of the bar that grows in response to a user
388     * swipe gesture.
389     *
390     * @param colors
391     */
392    public void setColorSchemeColors(int... colors) {
393        ensureTarget();
394        mProgress.setColorSchemeColors(colors);
395    }
396
397    /**
398     * @return Whether the SwipeRefreshWidget is actively showing refresh
399     *         progress.
400     */
401    public boolean isRefreshing() {
402        return mRefreshing;
403    }
404
405    private void ensureTarget() {
406        // Don't bother getting the parent height if the parent hasn't been laid
407        // out yet.
408        if (mTarget == null) {
409            for (int i = 0; i < getChildCount(); i++) {
410                View child = getChildAt(i);
411                if (!child.equals(mCircleView)) {
412                    mTarget = child;
413                    break;
414                }
415            }
416        }
417    }
418
419    /**
420     * Set the distance to trigger a sync in dips
421     *
422     * @param distance
423     */
424    public void setDistanceToTriggerSync(int distance) {
425        mDistanceToTriggerSync = distance;
426    }
427
428    @Override
429    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
430        final int width = getMeasuredWidth();
431        final int height = getMeasuredHeight();
432        if (getChildCount() == 0) {
433            return;
434        }
435        if (mTarget == null) {
436            ensureTarget();
437        }
438        if (mTarget == null) {
439            return;
440        }
441        final View child = mTarget;
442        final int childLeft = getPaddingLeft();
443        final int childTop = getPaddingTop();
444        final int childWidth = width - getPaddingLeft() - getPaddingRight();
445        final int childHeight = height - getPaddingTop() - getPaddingBottom();
446        child.layout(childLeft, childTop, childLeft + childWidth, childTop + childHeight);
447        mCircleView.layout((width / 2 - mCircleWidth / 2), mCurrentTargetOffsetTop,
448                (width / 2 + mCircleWidth / 2), mCurrentTargetOffsetTop + mCircleHeight);
449    }
450
451    @Override
452    public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
453        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
454        if (mTarget == null) {
455            ensureTarget();
456        }
457        if (mTarget == null) {
458            return;
459        }
460        mTarget.measure(MeasureSpec.makeMeasureSpec(
461                getMeasuredWidth() - getPaddingLeft() - getPaddingRight(),
462                MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(
463                getMeasuredHeight() - getPaddingTop() - getPaddingBottom(), MeasureSpec.EXACTLY));
464        mCircleView.measure(MeasureSpec.makeMeasureSpec(mCircleWidth, MeasureSpec.EXACTLY),
465                MeasureSpec.makeMeasureSpec(mCircleHeight, MeasureSpec.EXACTLY));
466    }
467
468    /**
469     * @return Whether it is possible for the child view of this layout to
470     *         scroll up. Override this if the child view is a custom view.
471     */
472    public boolean canChildScrollUp() {
473        if (android.os.Build.VERSION.SDK_INT < 14) {
474            if (mTarget instanceof AbsListView) {
475                final AbsListView absListView = (AbsListView) mTarget;
476                return absListView.getChildCount() > 0
477                        && (absListView.getFirstVisiblePosition() > 0 || absListView.getChildAt(0)
478                                .getTop() < absListView.getPaddingTop());
479            } else {
480                return mTarget.getScrollY() > 0;
481            }
482        } else {
483            return ViewCompat.canScrollVertically(mTarget, -1);
484        }
485    }
486
487    @Override
488    public boolean onInterceptTouchEvent(MotionEvent ev) {
489        ensureTarget();
490
491        final int action = MotionEventCompat.getActionMasked(ev);
492
493        if (mReturningToStart && action == MotionEvent.ACTION_DOWN) {
494            mReturningToStart = false;
495        }
496
497        if (!isEnabled() || mReturningToStart || canChildScrollUp() || mRefreshing) {
498            // Fail fast if we're not in a state where a swipe is possible
499            return false;
500        }
501
502        switch (action) {
503            case MotionEvent.ACTION_DOWN:
504                mLastY = mInitialMotionY = ev.getY();
505                setTargetOffsetTopAndBottom(mOriginalOffsetTop - mCircleView.getTop(), true);
506                mActivePointerId = MotionEventCompat.getPointerId(ev, 0);
507                mIsBeingDragged = false;
508                break;
509
510            case MotionEvent.ACTION_MOVE:
511                if (mActivePointerId == INVALID_POINTER) {
512                    Log.e(LOG_TAG, "Got ACTION_MOVE event but don't have an active pointer id.");
513                    return false;
514                }
515
516                final int pointerIndex = MotionEventCompat.findPointerIndex(ev, mActivePointerId);
517                if (pointerIndex < 0) {
518                    Log.e(LOG_TAG, "Got ACTION_MOVE event but have an invalid active pointer id.");
519                    return false;
520                }
521
522                final float y = MotionEventCompat.getY(ev, pointerIndex);
523                final float yDiff = y - mInitialMotionY;
524                if (yDiff > mTouchSlop) {
525                    mIsBeingDragged = true;
526                }
527                break;
528
529            case MotionEventCompat.ACTION_POINTER_UP:
530                onSecondaryPointerUp(ev);
531                break;
532
533            case MotionEvent.ACTION_UP:
534            case MotionEvent.ACTION_CANCEL:
535                mIsBeingDragged = false;
536                mActivePointerId = INVALID_POINTER;
537                break;
538        }
539
540        return mIsBeingDragged;
541    }
542
543    @Override
544    public void requestDisallowInterceptTouchEvent(boolean b) {
545        // Nope.
546    }
547
548    @Override
549    public boolean onTouchEvent(MotionEvent ev) {
550        final int action = MotionEventCompat.getActionMasked(ev);
551
552        if (mReturningToStart && action == MotionEvent.ACTION_DOWN) {
553            mReturningToStart = false;
554        }
555
556        if (!isEnabled() || mReturningToStart || canChildScrollUp()) {
557            // Fail fast if we're not in a state where a swipe is possible
558            return false;
559        }
560
561        switch (action) {
562            case MotionEvent.ACTION_DOWN:
563                mLastY = mInitialMotionY = ev.getY();
564                mActivePointerId = MotionEventCompat.getPointerId(ev, 0);
565                mIsBeingDragged = false;
566                break;
567
568            case MotionEvent.ACTION_MOVE:
569                final int pointerIndex = MotionEventCompat.findPointerIndex(ev, mActivePointerId);
570                if (pointerIndex < 0) {
571                    Log.e(LOG_TAG, "Got ACTION_MOVE event but have an invalid active pointer id.");
572                    return false;
573                }
574
575                final float y = MotionEventCompat.getY(ev, pointerIndex);
576                final float yDiff = y - mInitialMotionY;
577
578                if (!mIsBeingDragged && yDiff > mTouchSlop) {
579                    mIsBeingDragged = true;
580                }
581
582                if (mIsBeingDragged) {
583                    // User velocity passed min velocity; trigger a refresh
584                    if (yDiff > mDistanceToTriggerSync) {
585                        // Continue updating circle, but at a reduced rate
586                        float p = Math.max(0,
587                                Math.min((yDiff - mDistanceToTriggerSync), MAX_OVERSWIPE)
588                                        / MAX_OVERSWIPE / 2);
589                        double q = (p - Math.pow(p, 2));
590                        double extraDrag = (2 * MAX_OVERSWIPE * q);
591                        if (mTargetCirclePosition + extraDrag
592                                <= (mTargetCirclePosition + MAX_OVERSWIPE)) {
593                            setTargetOffsetTopAndBottom(
594                                    (int) (mTargetCirclePosition + extraDrag
595                                            - mCurrentTargetOffsetTop),
596                                    true /* requires update */);
597                        }
598                        if (mScale || mCurrentTargetOffsetTop > -mCircleHeight / 2) {
599                            // start showing the arrow
600                            float pullPercent = yDiff / mDistanceToTriggerSync * DRAG_RATE;
601                            mProgress.setProgressRotation((float) (Math.PI / 4 * pullPercent));
602                            mProgress.setArrowScale(1f);
603                        }
604                    } else {
605                        if (mCircleView.getVisibility() != View.VISIBLE) {
606                            mCircleView.setVisibility(View.VISIBLE);
607                        }
608                        mProgress.showArrow(true);
609                        if (mScale) {
610                            setAnimationProgress(yDiff / mDistanceToTriggerSync);
611                        }
612                        // Just track the user's movement
613                        setTargetOffsetTopAndBottom((int) ((y - mLastY) * DRAG_RATE),
614                                true /* requires update */);
615                        if (mScale || mCurrentTargetOffsetTop > -mCircleHeight / 2) {
616                            // start showing the arrow
617                            float pullPercent = yDiff / mDistanceToTriggerSync;
618                            float startTrim = (float) (.2 * Math.min(.75f, (pullPercent)));
619                            mProgress.setStartEndTrim(startTrim,
620                                    Math.min(.75f, pullPercent));
621                            mProgress.setProgressRotation((float) (Math.PI / 4 * pullPercent));
622                            mProgress.setArrowScale(Math.min(1f, pullPercent));
623                        }
624                    }
625                }
626                mLastY = y;
627                break;
628
629            case MotionEventCompat.ACTION_POINTER_DOWN: {
630                final int index = MotionEventCompat.getActionIndex(ev);
631                mActivePointerId = MotionEventCompat.getPointerId(ev, index);
632                break;
633            }
634
635            case MotionEventCompat.ACTION_POINTER_UP:
636                onSecondaryPointerUp(ev);
637                break;
638
639            case MotionEvent.ACTION_UP:
640            case MotionEvent.ACTION_CANCEL:
641                mIsBeingDragged = false;
642                if (mCurrentTargetOffsetTop >= mTargetCirclePosition) {
643                    setRefreshing(true, true /* notify */);
644                    mProgress.showArrow(false);
645                } else {
646                    // cancel refresh
647                    mRefreshing = false;
648                    mProgress.setStartEndTrim(0f, 0f);
649                    animateOffsetToStartPosition(mCurrentTargetOffsetTop, null);
650                    mProgress.showArrow(false);
651                }
652                mActivePointerId = INVALID_POINTER;
653                return false;
654        }
655
656        return true;
657    }
658
659    private void animateOffsetToCorrectPosition(int from, AnimationListener listener) {
660        mFrom = from;
661        mAnimateToCorrectPosition.reset();
662        mAnimateToCorrectPosition.setDuration(mMediumAnimationDuration);
663        mAnimateToCorrectPosition.setInterpolator(mDecelerateInterpolator);
664        if (listener != null) {
665            mCircleView.setAnimationListener(listener);
666        }
667        mCircleView.clearAnimation();
668        mCircleView.startAnimation(mAnimateToCorrectPosition);
669    }
670
671    private void animateOffsetToStartPosition(int from, AnimationListener listener) {
672        mFrom = from;
673        mAnimateToStartPosition.reset();
674        mAnimateToStartPosition.setDuration(mMediumAnimationDuration);
675        mAnimateToStartPosition.setInterpolator(mDecelerateInterpolator);
676        if (listener != null) {
677            mCircleView.setAnimationListener(listener);
678        }
679        mCircleView.clearAnimation();
680        mCircleView.startAnimation(mAnimateToStartPosition);
681    }
682
683    private final Animation mAnimateToCorrectPosition = new Animation() {
684        @Override
685        public void applyTransformation(float interpolatedTime, Transformation t) {
686            int targetTop = 0;
687            int endTarget = (int) (mTargetCirclePosition);
688            if (mFrom != endTarget) {
689                targetTop = (mFrom + (int) ((endTarget - mFrom) * interpolatedTime));
690            }
691            int offset = targetTop - mCircleView.getTop();
692            setTargetOffsetTopAndBottom(offset, false /* requires update */);
693        }
694    };
695
696    private final Animation mAnimateToStartPosition = new Animation() {
697        @Override
698        public void applyTransformation(float interpolatedTime, Transformation t) {
699            int targetTop = 0;
700            if (mFrom != mOriginalOffsetTop) {
701                targetTop = (mFrom + (int) ((mOriginalOffsetTop - mFrom) * interpolatedTime));
702            }
703            int offset = targetTop - mCircleView.getTop();
704            setTargetOffsetTopAndBottom(offset, false /* requires update */);
705        }
706    };
707
708    private void setTargetOffsetTopAndBottom(int offset, boolean requiresUpdate) {
709        mCircleView.bringToFront();
710        mCircleView.offsetTopAndBottom(offset);
711        mCurrentTargetOffsetTop = mCircleView.getTop();
712        if (requiresUpdate && android.os.Build.VERSION.SDK_INT <= 10) {
713            mCircleView.invalidate();
714        }
715    }
716
717    private void onSecondaryPointerUp(MotionEvent ev) {
718        final int pointerIndex = MotionEventCompat.getActionIndex(ev);
719        final int pointerId = MotionEventCompat.getPointerId(ev, pointerIndex);
720        if (pointerId == mActivePointerId) {
721            // This was our active pointer going up. Choose a new
722            // active pointer and adjust accordingly.
723            final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
724            mActivePointerId = MotionEventCompat.getPointerId(ev, newPointerIndex);
725        }
726    }
727
728    /**
729     * Classes that wish to be notified when the swipe gesture correctly
730     * triggers a refresh should implement this interface.
731     */
732    public interface OnRefreshListener {
733        public void onRefresh();
734    }
735}
736