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