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}