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