SwipeRefreshLayout.java revision 6611d8cf18999a874e37245e9ecf269e0e69846b
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.Canvas;
23import android.support.v4.view.ViewCompat;
24import android.util.AttributeSet;
25import android.util.DisplayMetrics;
26import android.view.MotionEvent;
27import android.view.View;
28import android.view.ViewConfiguration;
29import android.view.ViewGroup;
30import android.view.animation.AccelerateInterpolator;
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/**
39 * The SwipeRefreshLayout should be used whenever the user can refresh the
40 * contents of a view via a vertical swipe gesture. The activity that
41 * instantiates this view should add an OnRefreshListener to be notified
42 * whenever the swipe to refresh gesture is completed. The SwipeRefreshLayout
43 * will notify the listener each and every time the gesture is completed again;
44 * the listener is responsible for correctly determining when to actually
45 * initiate a refresh of its content. If the listener determines there should
46 * not be a refresh, it must call setRefreshing(false) to cancel any visual
47 * indication of a refresh. If an activity wishes to show just the progress
48 * animation, it should call setRefreshing(true). To disable the gesture and progress
49 * animation, call setEnabled(false) on the view.
50 *
51 * <p> This layout should be made the parent of the view that will be refreshed as a
52 * result of the gesture and can only support one direct child. This view will
53 * also be made the target of the gesture and will be forced to match both the
54 * width and the height supplied in this layout. The SwipeRefreshLayout does not
55 * provide accessibility events; instead, a menu item must be provided to allow
56 * refresh of the content wherever this gesture is used.</p>
57 */
58public class SwipeRefreshLayout extends ViewGroup {
59    private static final long RETURN_TO_ORIGINAL_POSITION_TIMEOUT = 300;
60    private static final float ACCELERATE_INTERPOLATION_FACTOR = 1.5f;
61    private static final float DECELERATE_INTERPOLATION_FACTOR = 2f;
62    private static final float PROGRESS_BAR_HEIGHT = 4;
63    private static final float MAX_SWIPE_DISTANCE_FACTOR = .6f;
64    private static final int REFRESH_TRIGGER_DISTANCE = 120;
65
66    private SwipeProgressBar mProgressBar; //the thing that shows progress is going
67    private View mTarget; //the content that gets pulled down
68    private int mOriginalOffsetTop;
69    private OnRefreshListener mListener;
70    private MotionEvent mDownEvent;
71    private int mFrom;
72    private boolean mRefreshing = false;
73    private int mTouchSlop;
74    private float mDistanceToTriggerSync = -1;
75    private float mPrevY;
76    private int mMediumAnimationDuration;
77    private float mFromPercentage = 0;
78    private float mCurrPercentage = 0;
79    private int mProgressBarHeight;
80    private int mCurrentTargetOffsetTop;
81    // Target is returning to its start offset because it was cancelled or a
82    // refresh was triggered.
83    private boolean mReturningToStart;
84    private final DecelerateInterpolator mDecelerateInterpolator;
85    private final AccelerateInterpolator mAccelerateInterpolator;
86    private static final int[] LAYOUT_ATTRS = new int[] {
87        android.R.attr.enabled
88    };
89
90    private final Animation mAnimateToStartPosition = new Animation() {
91        @Override
92        public void applyTransformation(float interpolatedTime, Transformation t) {
93            int targetTop = 0;
94            if (mFrom != mOriginalOffsetTop) {
95                targetTop = (mFrom + (int)((mOriginalOffsetTop - mFrom) * interpolatedTime));
96            }
97            int offset = targetTop - mTarget.getTop();
98            final int currentTop = mTarget.getTop();
99            if (offset + currentTop < 0) {
100                offset = 0 - currentTop;
101            }
102            setTargetOffsetTopAndBottom(offset);
103        }
104    };
105
106    private Animation mShrinkTrigger = new Animation() {
107        @Override
108        public void applyTransformation(float interpolatedTime, Transformation t) {
109            float percent = mFromPercentage + ((0 - mFromPercentage) * interpolatedTime);
110            mProgressBar.setTriggerPercentage(percent);
111        }
112    };
113
114    private final AnimationListener mReturnToStartPositionListener = new BaseAnimationListener() {
115        @Override
116        public void onAnimationEnd(Animation animation) {
117            // Once the target content has returned to its start position, reset
118            // the target offset to 0
119            mCurrentTargetOffsetTop = 0;
120        }
121    };
122
123    private final AnimationListener mShrinkAnimationListener = new BaseAnimationListener() {
124        @Override
125        public void onAnimationEnd(Animation animation) {
126            mCurrPercentage = 0;
127        }
128    };
129
130    private final Runnable mReturnToStartPosition = new Runnable() {
131
132        @Override
133        public void run() {
134            mReturningToStart = true;
135            animateOffsetToStartPosition(mCurrentTargetOffsetTop + getPaddingTop(),
136                    mReturnToStartPositionListener);
137        }
138
139    };
140
141    // Cancel the refresh gesture and animate everything back to its original state.
142    private final Runnable mCancel = new Runnable() {
143
144        @Override
145        public void run() {
146            mReturningToStart = true;
147            // Timeout fired since the user last moved their finger; animate the
148            // trigger to 0 and put the target back at its original position
149            if (mProgressBar != null) {
150                mFromPercentage = mCurrPercentage;
151                mShrinkTrigger.setDuration(mMediumAnimationDuration);
152                mShrinkTrigger.setAnimationListener(mShrinkAnimationListener);
153                mShrinkTrigger.reset();
154                mShrinkTrigger.setInterpolator(mDecelerateInterpolator);
155                startAnimation(mShrinkTrigger);
156            }
157            animateOffsetToStartPosition(mCurrentTargetOffsetTop + getPaddingTop(),
158                    mReturnToStartPositionListener);
159        }
160
161    };
162
163    /**
164     * Simple constructor to use when creating a SwipeRefreshLayout from code.
165     * @param context
166     */
167    public SwipeRefreshLayout(Context context) {
168        this(context, null);
169    }
170
171    /**
172     * Constructor that is called when inflating SwipeRefreshLayout from XML.
173     * @param context
174     * @param attrs
175     */
176    public SwipeRefreshLayout(Context context, AttributeSet attrs) {
177        super(context, attrs);
178
179        mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
180
181        mMediumAnimationDuration = getResources().getInteger(
182                android.R.integer.config_mediumAnimTime);
183
184        setWillNotDraw(false);
185        mProgressBar = new SwipeProgressBar(this);
186        final DisplayMetrics metrics = getResources().getDisplayMetrics();
187        mProgressBarHeight = (int) (metrics.density * PROGRESS_BAR_HEIGHT);
188        mDecelerateInterpolator = new DecelerateInterpolator(DECELERATE_INTERPOLATION_FACTOR);
189        mAccelerateInterpolator = new AccelerateInterpolator(ACCELERATE_INTERPOLATION_FACTOR);
190
191        final TypedArray a = context.obtainStyledAttributes(attrs, LAYOUT_ATTRS);
192        setEnabled(a.getBoolean(0, true));
193        a.recycle();
194    }
195
196    @Override
197    public void onAttachedToWindow() {
198        super.onAttachedToWindow();
199        removeCallbacks(mCancel);
200        removeCallbacks(mReturnToStartPosition);
201    }
202
203    @Override
204    public void onDetachedFromWindow() {
205        super.onDetachedFromWindow();
206        removeCallbacks(mReturnToStartPosition);
207        removeCallbacks(mCancel);
208    }
209
210    private void animateOffsetToStartPosition(int from, AnimationListener listener) {
211        mFrom = from;
212        mAnimateToStartPosition.reset();
213        mAnimateToStartPosition.setDuration(mMediumAnimationDuration);
214        mAnimateToStartPosition.setAnimationListener(listener);
215        mAnimateToStartPosition.setInterpolator(mDecelerateInterpolator);
216        mTarget.startAnimation(mAnimateToStartPosition);
217    }
218
219    /**
220     * Set the listener to be notified when a refresh is triggered via the swipe
221     * gesture.
222     */
223    public void setOnRefreshListener(OnRefreshListener listener) {
224        mListener = listener;
225    }
226
227    private void setTriggerPercentage(float percent) {
228        if (percent == 0f) {
229            // No-op. A null trigger means it's uninitialized, and setting it to zero-percent
230            // means we're trying to reset state, so there's nothing to reset in this case.
231            mCurrPercentage = 0;
232            return;
233        }
234        mCurrPercentage = percent;
235        mProgressBar.setTriggerPercentage(percent);
236    }
237
238    /**
239     * Notify the widget that refresh state has changed. Do not call this when
240     * refresh is triggered by a swipe gesture.
241     *
242     * @param refreshing Whether or not the view should show refresh progress.
243     */
244    public void setRefreshing(boolean refreshing) {
245        if (mRefreshing != refreshing) {
246            ensureTarget();
247            mCurrPercentage = 0;
248            mRefreshing = refreshing;
249            if (mRefreshing) {
250                mProgressBar.start();
251            } else {
252                mProgressBar.stop();
253            }
254        }
255    }
256
257    /**
258     * Set the four colors used in the progress animation. The first color will
259     * also be the color of the bar that grows in response to a user swipe
260     * gesture.
261     *
262     * @param colorRes1 Color resource.
263     * @param colorRes2 Color resource.
264     * @param colorRes3 Color resource.
265     * @param colorRes4 Color resource.
266     */
267    public void setColorScheme(int colorRes1, int colorRes2, int colorRes3, int colorRes4) {
268        ensureTarget();
269        final Resources res = getResources();
270        final int color1 = res.getColor(colorRes1);
271        final int color2 = res.getColor(colorRes2);
272        final int color3 = res.getColor(colorRes3);
273        final int color4 = res.getColor(colorRes4);
274        mProgressBar.setColorScheme(color1, color2, color3,color4);
275    }
276
277    /**
278     * @return Whether the SwipeRefreshWidget is actively showing refresh
279     *         progress.
280     */
281    public boolean isRefreshing() {
282        return mRefreshing;
283    }
284
285    private void ensureTarget() {
286        // Don't bother getting the parent height if the parent hasn't been laid out yet.
287        if (mTarget == null) {
288            if (getChildCount() > 1 && !isInEditMode()) {
289                throw new IllegalStateException(
290                        "SwipeRefreshLayout can host only one direct child");
291            }
292            mTarget = getChildAt(0);
293            mOriginalOffsetTop = mTarget.getTop() + getPaddingTop();
294        }
295        if (mDistanceToTriggerSync == -1) {
296            if (getParent() != null && ((View)getParent()).getHeight() > 0) {
297                final DisplayMetrics metrics = getResources().getDisplayMetrics();
298                mDistanceToTriggerSync = (int) Math.min(
299                        ((View) getParent()) .getHeight() * MAX_SWIPE_DISTANCE_FACTOR,
300                                REFRESH_TRIGGER_DISTANCE * metrics.density);
301            }
302        }
303    }
304
305    @Override
306    public void draw(Canvas canvas) {
307        super.draw(canvas);
308        mProgressBar.draw(canvas);
309    }
310
311    @Override
312    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
313        final int width =  getMeasuredWidth();
314        final int height = getMeasuredHeight();
315        mProgressBar.setBounds(0, 0, width, mProgressBarHeight);
316        if (getChildCount() == 0) {
317            return;
318        }
319        final View child = getChildAt(0);
320        final int childLeft = getPaddingLeft();
321        final int childTop = mCurrentTargetOffsetTop + getPaddingTop();
322        final int childWidth = width - getPaddingLeft() - getPaddingRight();
323        final int childHeight = height - getPaddingTop() - getPaddingBottom();
324        child.layout(childLeft, childTop, childLeft + childWidth, childTop + childHeight);
325    }
326
327    @Override
328    public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
329        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
330        if (getChildCount() > 1 && !isInEditMode()) {
331            throw new IllegalStateException("SwipeRefreshLayout can host only one direct child");
332        }
333        if (getChildCount() > 0) {
334            getChildAt(0).measure(
335                    MeasureSpec.makeMeasureSpec(
336                            getMeasuredWidth() - getPaddingLeft() - getPaddingRight(),
337                            MeasureSpec.EXACTLY),
338                    MeasureSpec.makeMeasureSpec(
339                            getMeasuredHeight() - getPaddingTop() - getPaddingBottom(),
340                            MeasureSpec.EXACTLY));
341        }
342    }
343
344    /**
345     * @return Whether it is possible for the child view of this layout to
346     *         scroll up. Override this if the child view is a custom view.
347     */
348    public boolean canChildScrollUp() {
349        if (android.os.Build.VERSION.SDK_INT < 14) {
350            if (mTarget instanceof AbsListView) {
351                final AbsListView absListView = (AbsListView) mTarget;
352                return absListView.getChildCount() > 0
353                        && (absListView.getFirstVisiblePosition() > 0 || absListView.getChildAt(0)
354                                .getTop() < absListView.getPaddingTop());
355            } else {
356                return mTarget.getScrollY() > 0;
357            }
358        } else {
359            return ViewCompat.canScrollVertically(mTarget, -1);
360        }
361    }
362
363    @Override
364    public boolean onInterceptTouchEvent(MotionEvent ev) {
365        ensureTarget();
366        boolean handled = false;
367        if (mReturningToStart && ev.getAction() == MotionEvent.ACTION_DOWN) {
368            mReturningToStart = false;
369        }
370        if (isEnabled() && !mReturningToStart && !canChildScrollUp()) {
371            handled = onTouchEvent(ev);
372        }
373        return !handled ? super.onInterceptTouchEvent(ev) : handled;
374    }
375
376    @Override
377    public void requestDisallowInterceptTouchEvent(boolean b) {
378        // Nope.
379    }
380
381    @Override
382    public boolean onTouchEvent(MotionEvent event) {
383        final int action = event.getAction();
384        boolean handled = false;
385        switch (action) {
386            case MotionEvent.ACTION_DOWN:
387                mCurrPercentage = 0;
388                mDownEvent = MotionEvent.obtain(event);
389                mPrevY = mDownEvent.getY();
390                break;
391            case MotionEvent.ACTION_MOVE:
392                if (mDownEvent != null && !mReturningToStart) {
393                    final float eventY = event.getY();
394                    float yDiff = eventY - mDownEvent.getY();
395                    if (yDiff > mTouchSlop) {
396                        // User velocity passed min velocity; trigger a refresh
397                        if (yDiff > mDistanceToTriggerSync) {
398                            // User movement passed distance; trigger a refresh
399                            startRefresh();
400                            handled = true;
401                            break;
402                        } else {
403                            // Just track the user's movement
404                            setTriggerPercentage(
405                                    mAccelerateInterpolator.getInterpolation(
406                                            yDiff / mDistanceToTriggerSync));
407                            float offsetTop = yDiff;
408                            if (mPrevY > eventY) {
409                                offsetTop = yDiff - mTouchSlop;
410                            }
411                            updateContentOffsetTop((int) (offsetTop));
412                            if (mPrevY > eventY && (mTarget.getTop() < mTouchSlop)) {
413                                // If the user puts the view back at the top, we
414                                // don't need to. This shouldn't be considered
415                                // cancelling the gesture as the user can restart from the top.
416                                removeCallbacks(mCancel);
417                            } else {
418                                updatePositionTimeout();
419                            }
420                            mPrevY = event.getY();
421                            handled = true;
422                        }
423                    }
424                }
425                break;
426            case MotionEvent.ACTION_UP:
427            case MotionEvent.ACTION_CANCEL:
428                if (mDownEvent != null) {
429                    mDownEvent.recycle();
430                    mDownEvent = null;
431                }
432                break;
433        }
434        return handled;
435    }
436
437    private void startRefresh() {
438        removeCallbacks(mCancel);
439        mReturnToStartPosition.run();
440        setRefreshing(true);
441        mListener.onRefresh();
442    }
443
444    private void updateContentOffsetTop(int targetTop) {
445        final int currentTop = mTarget.getTop();
446        if (targetTop > mDistanceToTriggerSync) {
447            targetTop = (int) mDistanceToTriggerSync;
448        } else if (targetTop < 0) {
449            targetTop = 0;
450        }
451        setTargetOffsetTopAndBottom(targetTop - currentTop);
452    }
453
454    private void setTargetOffsetTopAndBottom(int offset) {
455        mTarget.offsetTopAndBottom(offset);
456        mCurrentTargetOffsetTop = mTarget.getTop();
457    }
458
459    private void updatePositionTimeout() {
460        removeCallbacks(mCancel);
461        postDelayed(mCancel, RETURN_TO_ORIGINAL_POSITION_TIMEOUT);
462    }
463
464    /**
465     * Classes that wish to be notified when the swipe gesture correctly
466     * triggers a refresh should implement this interface.
467     */
468    public interface OnRefreshListener {
469        public void onRefresh();
470    }
471
472    /**
473     * Simple AnimationListener to avoid having to implement unneeded methods in
474     * AnimationListeners.
475     */
476    private class BaseAnimationListener implements AnimationListener {
477        @Override
478        public void onAnimationStart(Animation animation) {
479        }
480
481        @Override
482        public void onAnimationEnd(Animation animation) {
483        }
484
485        @Override
486        public void onAnimationRepeat(Animation animation) {
487        }
488    }
489}