SwipeRefreshLayout.java revision 6ed40c1f86bcb172a1f0f069cde1c571a7781aee
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.annotation.SuppressLint; 20import android.content.Context; 21import android.content.res.TypedArray; 22import android.support.annotation.ColorInt; 23import android.support.annotation.ColorRes; 24import android.support.annotation.Nullable; 25import android.support.annotation.VisibleForTesting; 26import android.support.v4.content.ContextCompat; 27import android.support.v4.view.NestedScrollingChild; 28import android.support.v4.view.NestedScrollingChildHelper; 29import android.support.v4.view.NestedScrollingParent; 30import android.support.v4.view.NestedScrollingParentHelper; 31import android.support.v4.view.ViewCompat; 32import android.util.AttributeSet; 33import android.util.DisplayMetrics; 34import android.util.Log; 35import android.view.MotionEvent; 36import android.view.View; 37import android.view.ViewConfiguration; 38import android.view.ViewGroup; 39import android.view.animation.Animation; 40import android.view.animation.Animation.AnimationListener; 41import android.view.animation.DecelerateInterpolator; 42import android.view.animation.Transformation; 43import android.widget.AbsListView; 44 45/** 46 * The SwipeRefreshLayout should be used whenever the user can refresh the 47 * contents of a view via a vertical swipe gesture. The activity that 48 * instantiates this view should add an OnRefreshListener to be notified 49 * whenever the swipe to refresh gesture is completed. The SwipeRefreshLayout 50 * will notify the listener each and every time the gesture is completed again; 51 * the listener is responsible for correctly determining when to actually 52 * initiate a refresh of its content. If the listener determines there should 53 * not be a refresh, it must call setRefreshing(false) to cancel any visual 54 * indication of a refresh. If an activity wishes to show just the progress 55 * animation, it should call setRefreshing(true). To disable the gesture and 56 * progress animation, call setEnabled(false) on the view. 57 * <p> 58 * This layout should be made the parent of the view that will be refreshed as a 59 * result of the gesture and can only support one direct child. This view will 60 * also be made the target of the gesture and will be forced to match both the 61 * width and the height supplied in this layout. The SwipeRefreshLayout does not 62 * provide accessibility events; instead, a menu item must be provided to allow 63 * refresh of the content wherever this gesture is used. 64 * </p> 65 */ 66public class SwipeRefreshLayout extends ViewGroup implements NestedScrollingParent, 67 NestedScrollingChild { 68 // Maps to ProgressBar.Large style 69 public static final int LARGE = MaterialProgressDrawable.LARGE; 70 // Maps to ProgressBar default style 71 public static final int DEFAULT = MaterialProgressDrawable.DEFAULT; 72 73 @VisibleForTesting 74 static final int CIRCLE_DIAMETER = 40; 75 @VisibleForTesting 76 static final int CIRCLE_DIAMETER_LARGE = 56; 77 78 private static final String LOG_TAG = SwipeRefreshLayout.class.getSimpleName(); 79 80 private static final int MAX_ALPHA = 255; 81 private static final int STARTING_PROGRESS_ALPHA = (int) (.3f * MAX_ALPHA); 82 83 private static final float DECELERATE_INTERPOLATION_FACTOR = 2f; 84 private static final int INVALID_POINTER = -1; 85 private static final float DRAG_RATE = .5f; 86 87 // Max amount of circle that can be filled by progress during swipe gesture, 88 // where 1.0 is a full circle 89 private static final float MAX_PROGRESS_ANGLE = .8f; 90 91 private static final int SCALE_DOWN_DURATION = 150; 92 93 private static final int ALPHA_ANIMATION_DURATION = 300; 94 95 private static final int ANIMATE_TO_TRIGGER_DURATION = 200; 96 97 private static final int ANIMATE_TO_START_DURATION = 200; 98 99 // Default background for the progress spinner 100 private static final int CIRCLE_BG_LIGHT = 0xFFFAFAFA; 101 // Default offset in dips from the top of the view to where the progress spinner should stop 102 private static final int DEFAULT_CIRCLE_TARGET = 64; 103 104 private View mTarget; // the target of the gesture 105 OnRefreshListener mListener; 106 boolean mRefreshing = false; 107 private int mTouchSlop; 108 private float mTotalDragDistance = -1; 109 110 // If nested scrolling is enabled, the total amount that needed to be 111 // consumed by this as the nested scrolling parent is used in place of the 112 // overscroll determined by MOVE events in the onTouch handler 113 private float mTotalUnconsumed; 114 private final NestedScrollingParentHelper mNestedScrollingParentHelper; 115 private final NestedScrollingChildHelper mNestedScrollingChildHelper; 116 private final int[] mParentScrollConsumed = new int[2]; 117 private final int[] mParentOffsetInWindow = new int[2]; 118 private boolean mNestedScrollInProgress; 119 120 private int mMediumAnimationDuration; 121 int mCurrentTargetOffsetTop; 122 123 private float mInitialMotionY; 124 private float mInitialDownY; 125 private boolean mIsBeingDragged; 126 private int mActivePointerId = INVALID_POINTER; 127 // Whether this item is scaled up rather than clipped 128 boolean mScale; 129 130 // Target is returning to its start offset because it was cancelled or a 131 // refresh was triggered. 132 private boolean mReturningToStart; 133 private final DecelerateInterpolator mDecelerateInterpolator; 134 private static final int[] LAYOUT_ATTRS = new int[] { 135 android.R.attr.enabled 136 }; 137 138 CircleImageView mCircleView; 139 private int mCircleViewIndex = -1; 140 141 protected int mFrom; 142 143 float mStartingScale; 144 145 protected int mOriginalOffsetTop; 146 147 int mSpinnerOffsetEnd; 148 149 MaterialProgressDrawable mProgress; 150 151 private Animation mScaleAnimation; 152 153 private Animation mScaleDownAnimation; 154 155 private Animation mAlphaStartAnimation; 156 157 private Animation mAlphaMaxAnimation; 158 159 private Animation mScaleDownToStartAnimation; 160 161 boolean mNotify; 162 163 private int mCircleDiameter; 164 165 // Whether the client has set a custom starting position; 166 boolean mUsingCustomStart; 167 168 private OnChildScrollUpCallback mChildScrollUpCallback; 169 170 private Animation.AnimationListener mRefreshListener = new Animation.AnimationListener() { 171 @Override 172 public void onAnimationStart(Animation animation) { 173 } 174 175 @Override 176 public void onAnimationRepeat(Animation animation) { 177 } 178 179 @SuppressLint("NewApi") 180 @Override 181 public void onAnimationEnd(Animation animation) { 182 if (mRefreshing) { 183 // Make sure the progress view is fully visible 184 mProgress.setAlpha(MAX_ALPHA); 185 mProgress.start(); 186 if (mNotify) { 187 if (mListener != null) { 188 mListener.onRefresh(); 189 } 190 } 191 mCurrentTargetOffsetTop = mCircleView.getTop(); 192 } else { 193 reset(); 194 } 195 } 196 }; 197 198 void reset() { 199 mCircleView.clearAnimation(); 200 mProgress.stop(); 201 mCircleView.setVisibility(View.GONE); 202 setColorViewAlpha(MAX_ALPHA); 203 // Return the circle to its start position 204 if (mScale) { 205 setAnimationProgress(0 /* animation complete and view is hidden */); 206 } else { 207 setTargetOffsetTopAndBottom(mOriginalOffsetTop - mCurrentTargetOffsetTop); 208 } 209 mCurrentTargetOffsetTop = mCircleView.getTop(); 210 } 211 212 @Override 213 public void setEnabled(boolean enabled) { 214 super.setEnabled(enabled); 215 if (!enabled) { 216 reset(); 217 } 218 } 219 220 @Override 221 protected void onDetachedFromWindow() { 222 super.onDetachedFromWindow(); 223 reset(); 224 } 225 226 @SuppressLint("NewApi") 227 private void setColorViewAlpha(int targetAlpha) { 228 mCircleView.getBackground().setAlpha(targetAlpha); 229 mProgress.setAlpha(targetAlpha); 230 } 231 232 /** 233 * The refresh indicator starting and resting position is always positioned 234 * near the top of the refreshing content. This position is a consistent 235 * location, but can be adjusted in either direction based on whether or not 236 * there is a toolbar or actionbar present. 237 * <p> 238 * <strong>Note:</strong> Calling this will reset the position of the refresh indicator to 239 * <code>start</code>. 240 * </p> 241 * 242 * @param scale Set to true if there is no view at a higher z-order than where the progress 243 * spinner is set to appear. Setting it to true will cause indicator to be scaled 244 * up rather than clipped. 245 * @param start The offset in pixels from the top of this view at which the 246 * progress spinner should appear. 247 * @param end The offset in pixels from the top of this view at which the 248 * progress spinner should come to rest after a successful swipe 249 * gesture. 250 */ 251 public void setProgressViewOffset(boolean scale, int start, int end) { 252 mScale = scale; 253 mOriginalOffsetTop = start; 254 mSpinnerOffsetEnd = end; 255 mUsingCustomStart = true; 256 reset(); 257 mRefreshing = false; 258 } 259 260 /** 261 * @return The offset in pixels from the top of this view at which the progress spinner should 262 * appear. 263 */ 264 public int getProgressViewStartOffset() { 265 return mOriginalOffsetTop; 266 } 267 268 /** 269 * @return The offset in pixels from the top of this view at which the progress spinner should 270 * come to rest after a successful swipe gesture. 271 */ 272 public int getProgressViewEndOffset() { 273 return mSpinnerOffsetEnd; 274 } 275 276 /** 277 * The refresh indicator resting position is always positioned near the top 278 * of the refreshing content. This position is a consistent location, but 279 * can be adjusted in either direction based on whether or not there is a 280 * toolbar or actionbar present. 281 * 282 * @param scale Set to true if there is no view at a higher z-order than where the progress 283 * spinner is set to appear. Setting it to true will cause indicator to be scaled 284 * up rather than clipped. 285 * @param end The offset in pixels from the top of this view at which the 286 * progress spinner should come to rest after a successful swipe 287 * gesture. 288 */ 289 public void setProgressViewEndTarget(boolean scale, int end) { 290 mSpinnerOffsetEnd = end; 291 mScale = scale; 292 mCircleView.invalidate(); 293 } 294 295 /** 296 * One of DEFAULT, or LARGE. 297 */ 298 public void setSize(int size) { 299 if (size != MaterialProgressDrawable.LARGE && size != MaterialProgressDrawable.DEFAULT) { 300 return; 301 } 302 final DisplayMetrics metrics = getResources().getDisplayMetrics(); 303 if (size == MaterialProgressDrawable.LARGE) { 304 mCircleDiameter = (int) (CIRCLE_DIAMETER_LARGE * metrics.density); 305 } else { 306 mCircleDiameter = (int) (CIRCLE_DIAMETER * metrics.density); 307 } 308 // force the bounds of the progress circle inside the circle view to 309 // update by setting it to null before updating its size and then 310 // re-setting it 311 mCircleView.setImageDrawable(null); 312 mProgress.updateSizes(size); 313 mCircleView.setImageDrawable(mProgress); 314 } 315 316 /** 317 * Simple constructor to use when creating a SwipeRefreshLayout from code. 318 * 319 * @param context 320 */ 321 public SwipeRefreshLayout(Context context) { 322 this(context, null); 323 } 324 325 /** 326 * Constructor that is called when inflating SwipeRefreshLayout from XML. 327 * 328 * @param context 329 * @param attrs 330 */ 331 public SwipeRefreshLayout(Context context, AttributeSet attrs) { 332 super(context, attrs); 333 334 mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); 335 336 mMediumAnimationDuration = getResources().getInteger( 337 android.R.integer.config_mediumAnimTime); 338 339 setWillNotDraw(false); 340 mDecelerateInterpolator = new DecelerateInterpolator(DECELERATE_INTERPOLATION_FACTOR); 341 342 final DisplayMetrics metrics = getResources().getDisplayMetrics(); 343 mCircleDiameter = (int) (CIRCLE_DIAMETER * metrics.density); 344 345 createProgressView(); 346 ViewCompat.setChildrenDrawingOrderEnabled(this, true); 347 // the absolute offset has to take into account that the circle starts at an offset 348 mSpinnerOffsetEnd = (int) (DEFAULT_CIRCLE_TARGET * metrics.density); 349 mTotalDragDistance = mSpinnerOffsetEnd; 350 mNestedScrollingParentHelper = new NestedScrollingParentHelper(this); 351 352 mNestedScrollingChildHelper = new NestedScrollingChildHelper(this); 353 setNestedScrollingEnabled(true); 354 355 mOriginalOffsetTop = mCurrentTargetOffsetTop = -mCircleDiameter; 356 moveToStart(1.0f); 357 358 final TypedArray a = context.obtainStyledAttributes(attrs, LAYOUT_ATTRS); 359 setEnabled(a.getBoolean(0, true)); 360 a.recycle(); 361 } 362 363 @Override 364 protected int getChildDrawingOrder(int childCount, int i) { 365 if (mCircleViewIndex < 0) { 366 return i; 367 } else if (i == childCount - 1) { 368 // Draw the selected child last 369 return mCircleViewIndex; 370 } else if (i >= mCircleViewIndex) { 371 // Move the children after the selected child earlier one 372 return i + 1; 373 } else { 374 // Keep the children before the selected child the same 375 return i; 376 } 377 } 378 379 private void createProgressView() { 380 mCircleView = new CircleImageView(getContext(), CIRCLE_BG_LIGHT); 381 mProgress = new MaterialProgressDrawable(getContext(), this); 382 mProgress.setBackgroundColor(CIRCLE_BG_LIGHT); 383 mCircleView.setImageDrawable(mProgress); 384 mCircleView.setVisibility(View.GONE); 385 addView(mCircleView); 386 } 387 388 /** 389 * Set the listener to be notified when a refresh is triggered via the swipe 390 * gesture. 391 */ 392 public void setOnRefreshListener(OnRefreshListener listener) { 393 mListener = listener; 394 } 395 396 /** 397 * Notify the widget that refresh state has changed. Do not call this when 398 * refresh is triggered by a swipe gesture. 399 * 400 * @param refreshing Whether or not the view should show refresh progress. 401 */ 402 public void setRefreshing(boolean refreshing) { 403 if (refreshing && mRefreshing != refreshing) { 404 // scale and show 405 mRefreshing = refreshing; 406 int endTarget = 0; 407 if (!mUsingCustomStart) { 408 endTarget = mSpinnerOffsetEnd + mOriginalOffsetTop; 409 } else { 410 endTarget = mSpinnerOffsetEnd; 411 } 412 setTargetOffsetTopAndBottom(endTarget - mCurrentTargetOffsetTop); 413 mNotify = false; 414 startScaleUpAnimation(mRefreshListener); 415 } else { 416 setRefreshing(refreshing, false /* notify */); 417 } 418 } 419 420 @SuppressLint("NewApi") 421 private void startScaleUpAnimation(AnimationListener listener) { 422 mCircleView.setVisibility(View.VISIBLE); 423 if (android.os.Build.VERSION.SDK_INT >= 11) { 424 // Pre API 11, alpha is used in place of scale up to show the 425 // progress circle appearing. 426 // Don't adjust the alpha during appearance otherwise. 427 mProgress.setAlpha(MAX_ALPHA); 428 } 429 mScaleAnimation = new Animation() { 430 @Override 431 public void applyTransformation(float interpolatedTime, Transformation t) { 432 setAnimationProgress(interpolatedTime); 433 } 434 }; 435 mScaleAnimation.setDuration(mMediumAnimationDuration); 436 if (listener != null) { 437 mCircleView.setAnimationListener(listener); 438 } 439 mCircleView.clearAnimation(); 440 mCircleView.startAnimation(mScaleAnimation); 441 } 442 443 /** 444 * Pre API 11, this does an alpha animation. 445 * @param progress 446 */ 447 void setAnimationProgress(float progress) { 448 mCircleView.setScaleX(progress); 449 mCircleView.setScaleY(progress); 450 } 451 452 private void setRefreshing(boolean refreshing, final boolean notify) { 453 if (mRefreshing != refreshing) { 454 mNotify = notify; 455 ensureTarget(); 456 mRefreshing = refreshing; 457 if (mRefreshing) { 458 animateOffsetToCorrectPosition(mCurrentTargetOffsetTop, mRefreshListener); 459 } else { 460 startScaleDownAnimation(mRefreshListener); 461 } 462 } 463 } 464 465 void startScaleDownAnimation(Animation.AnimationListener listener) { 466 mScaleDownAnimation = new Animation() { 467 @Override 468 public void applyTransformation(float interpolatedTime, Transformation t) { 469 setAnimationProgress(1 - interpolatedTime); 470 } 471 }; 472 mScaleDownAnimation.setDuration(SCALE_DOWN_DURATION); 473 mCircleView.setAnimationListener(listener); 474 mCircleView.clearAnimation(); 475 mCircleView.startAnimation(mScaleDownAnimation); 476 } 477 478 private void startProgressAlphaStartAnimation() { 479 mAlphaStartAnimation = startAlphaAnimation(mProgress.getAlpha(), STARTING_PROGRESS_ALPHA); 480 } 481 482 private void startProgressAlphaMaxAnimation() { 483 mAlphaMaxAnimation = startAlphaAnimation(mProgress.getAlpha(), MAX_ALPHA); 484 } 485 486 private Animation startAlphaAnimation(final int startingAlpha, final int endingAlpha) { 487 Animation alpha = new Animation() { 488 @Override 489 public void applyTransformation(float interpolatedTime, Transformation t) { 490 mProgress.setAlpha( 491 (int) (startingAlpha + ((endingAlpha - startingAlpha) * interpolatedTime))); 492 } 493 }; 494 alpha.setDuration(ALPHA_ANIMATION_DURATION); 495 // Clear out the previous animation listeners. 496 mCircleView.setAnimationListener(null); 497 mCircleView.clearAnimation(); 498 mCircleView.startAnimation(alpha); 499 return alpha; 500 } 501 502 /** 503 * @deprecated Use {@link #setProgressBackgroundColorSchemeResource(int)} 504 */ 505 @Deprecated 506 public void setProgressBackgroundColor(int colorRes) { 507 setProgressBackgroundColorSchemeResource(colorRes); 508 } 509 510 /** 511 * Set the background color of the progress spinner disc. 512 * 513 * @param colorRes Resource id of the color. 514 */ 515 public void setProgressBackgroundColorSchemeResource(@ColorRes int colorRes) { 516 setProgressBackgroundColorSchemeColor(ContextCompat.getColor(getContext(), colorRes)); 517 } 518 519 /** 520 * Set the background color of the progress spinner disc. 521 * 522 * @param color 523 */ 524 public void setProgressBackgroundColorSchemeColor(@ColorInt int color) { 525 mCircleView.setBackgroundColor(color); 526 mProgress.setBackgroundColor(color); 527 } 528 529 /** 530 * @deprecated Use {@link #setColorSchemeResources(int...)} 531 */ 532 @Deprecated 533 public void setColorScheme(@ColorInt int... colors) { 534 setColorSchemeResources(colors); 535 } 536 537 /** 538 * Set the color resources used in the progress animation from color resources. 539 * The first color will also be the color of the bar that grows in response 540 * to a user swipe gesture. 541 * 542 * @param colorResIds 543 */ 544 public void setColorSchemeResources(@ColorRes int... colorResIds) { 545 final Context context = getContext(); 546 int[] colorRes = new int[colorResIds.length]; 547 for (int i = 0; i < colorResIds.length; i++) { 548 colorRes[i] = ContextCompat.getColor(context, colorResIds[i]); 549 } 550 setColorSchemeColors(colorRes); 551 } 552 553 /** 554 * Set the colors used in the progress animation. The first 555 * color will also be the color of the bar that grows in response to a user 556 * swipe gesture. 557 * 558 * @param colors 559 */ 560 public void setColorSchemeColors(@ColorInt int... colors) { 561 ensureTarget(); 562 mProgress.setColorSchemeColors(colors); 563 } 564 565 /** 566 * @return Whether the SwipeRefreshWidget is actively showing refresh 567 * progress. 568 */ 569 public boolean isRefreshing() { 570 return mRefreshing; 571 } 572 573 private void ensureTarget() { 574 // Don't bother getting the parent height if the parent hasn't been laid 575 // out yet. 576 if (mTarget == null) { 577 for (int i = 0; i < getChildCount(); i++) { 578 View child = getChildAt(i); 579 if (!child.equals(mCircleView)) { 580 mTarget = child; 581 break; 582 } 583 } 584 } 585 } 586 587 /** 588 * Set the distance to trigger a sync in dips 589 * 590 * @param distance 591 */ 592 public void setDistanceToTriggerSync(int distance) { 593 mTotalDragDistance = distance; 594 } 595 596 @Override 597 protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 598 final int width = getMeasuredWidth(); 599 final int height = getMeasuredHeight(); 600 if (getChildCount() == 0) { 601 return; 602 } 603 if (mTarget == null) { 604 ensureTarget(); 605 } 606 if (mTarget == null) { 607 return; 608 } 609 final View child = mTarget; 610 final int childLeft = getPaddingLeft(); 611 final int childTop = getPaddingTop(); 612 final int childWidth = width - getPaddingLeft() - getPaddingRight(); 613 final int childHeight = height - getPaddingTop() - getPaddingBottom(); 614 child.layout(childLeft, childTop, childLeft + childWidth, childTop + childHeight); 615 int circleWidth = mCircleView.getMeasuredWidth(); 616 int circleHeight = mCircleView.getMeasuredHeight(); 617 mCircleView.layout((width / 2 - circleWidth / 2), mCurrentTargetOffsetTop, 618 (width / 2 + circleWidth / 2), mCurrentTargetOffsetTop + circleHeight); 619 } 620 621 @Override 622 public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 623 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 624 if (mTarget == null) { 625 ensureTarget(); 626 } 627 if (mTarget == null) { 628 return; 629 } 630 mTarget.measure(MeasureSpec.makeMeasureSpec( 631 getMeasuredWidth() - getPaddingLeft() - getPaddingRight(), 632 MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec( 633 getMeasuredHeight() - getPaddingTop() - getPaddingBottom(), MeasureSpec.EXACTLY)); 634 mCircleView.measure(MeasureSpec.makeMeasureSpec(mCircleDiameter, MeasureSpec.EXACTLY), 635 MeasureSpec.makeMeasureSpec(mCircleDiameter, MeasureSpec.EXACTLY)); 636 mCircleViewIndex = -1; 637 // Get the index of the circleview. 638 for (int index = 0; index < getChildCount(); index++) { 639 if (getChildAt(index) == mCircleView) { 640 mCircleViewIndex = index; 641 break; 642 } 643 } 644 } 645 646 /** 647 * Get the diameter of the progress circle that is displayed as part of the 648 * swipe to refresh layout. 649 * 650 * @return Diameter in pixels of the progress circle view. 651 */ 652 public int getProgressCircleDiameter() { 653 return mCircleDiameter; 654 } 655 656 /** 657 * @return Whether it is possible for the child view of this layout to 658 * scroll up. Override this if the child view is a custom view. 659 */ 660 public boolean canChildScrollUp() { 661 if (mChildScrollUpCallback != null) { 662 return mChildScrollUpCallback.canChildScrollUp(this, mTarget); 663 } 664 return ViewCompat.canScrollVertically(mTarget, -1); 665 } 666 667 /** 668 * Set a callback to override {@link SwipeRefreshLayout#canChildScrollUp()} method. Non-null 669 * callback will return the value provided by the callback and ignore all internal logic. 670 * @param callback Callback that should be called when canChildScrollUp() is called. 671 */ 672 public void setOnChildScrollUpCallback(@Nullable OnChildScrollUpCallback callback) { 673 mChildScrollUpCallback = callback; 674 } 675 676 @Override 677 public boolean onInterceptTouchEvent(MotionEvent ev) { 678 ensureTarget(); 679 680 final int action = ev.getActionMasked(); 681 int pointerIndex; 682 683 if (mReturningToStart && action == MotionEvent.ACTION_DOWN) { 684 mReturningToStart = false; 685 } 686 687 if (!isEnabled() || mReturningToStart || canChildScrollUp() 688 || mRefreshing || mNestedScrollInProgress) { 689 // Fail fast if we're not in a state where a swipe is possible 690 return false; 691 } 692 693 switch (action) { 694 case MotionEvent.ACTION_DOWN: 695 setTargetOffsetTopAndBottom(mOriginalOffsetTop - mCircleView.getTop()); 696 mActivePointerId = ev.getPointerId(0); 697 mIsBeingDragged = false; 698 699 pointerIndex = ev.findPointerIndex(mActivePointerId); 700 if (pointerIndex < 0) { 701 return false; 702 } 703 mInitialDownY = ev.getY(pointerIndex); 704 break; 705 706 case MotionEvent.ACTION_MOVE: 707 if (mActivePointerId == INVALID_POINTER) { 708 Log.e(LOG_TAG, "Got ACTION_MOVE event but don't have an active pointer id."); 709 return false; 710 } 711 712 pointerIndex = ev.findPointerIndex(mActivePointerId); 713 if (pointerIndex < 0) { 714 return false; 715 } 716 final float y = ev.getY(pointerIndex); 717 startDragging(y); 718 break; 719 720 case MotionEvent.ACTION_POINTER_UP: 721 onSecondaryPointerUp(ev); 722 break; 723 724 case MotionEvent.ACTION_UP: 725 case MotionEvent.ACTION_CANCEL: 726 mIsBeingDragged = false; 727 mActivePointerId = INVALID_POINTER; 728 break; 729 } 730 731 return mIsBeingDragged; 732 } 733 734 @Override 735 public void requestDisallowInterceptTouchEvent(boolean b) { 736 // if this is a List < L or another view that doesn't support nested 737 // scrolling, ignore this request so that the vertical scroll event 738 // isn't stolen 739 if ((android.os.Build.VERSION.SDK_INT < 21 && mTarget instanceof AbsListView) 740 || (mTarget != null && !ViewCompat.isNestedScrollingEnabled(mTarget))) { 741 // Nope. 742 } else { 743 super.requestDisallowInterceptTouchEvent(b); 744 } 745 } 746 747 // NestedScrollingParent 748 749 @Override 750 public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) { 751 return isEnabled() && !mReturningToStart && !mRefreshing 752 && (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0; 753 } 754 755 @Override 756 public void onNestedScrollAccepted(View child, View target, int axes) { 757 // Reset the counter of how much leftover scroll needs to be consumed. 758 mNestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes); 759 // Dispatch up to the nested parent 760 startNestedScroll(axes & ViewCompat.SCROLL_AXIS_VERTICAL); 761 mTotalUnconsumed = 0; 762 mNestedScrollInProgress = true; 763 } 764 765 @Override 766 public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) { 767 // If we are in the middle of consuming, a scroll, then we want to move the spinner back up 768 // before allowing the list to scroll 769 if (dy > 0 && mTotalUnconsumed > 0) { 770 if (dy > mTotalUnconsumed) { 771 consumed[1] = dy - (int) mTotalUnconsumed; 772 mTotalUnconsumed = 0; 773 } else { 774 mTotalUnconsumed -= dy; 775 consumed[1] = dy; 776 } 777 moveSpinner(mTotalUnconsumed); 778 } 779 780 // If a client layout is using a custom start position for the circle 781 // view, they mean to hide it again before scrolling the child view 782 // If we get back to mTotalUnconsumed == 0 and there is more to go, hide 783 // the circle so it isn't exposed if its blocking content is moved 784 if (mUsingCustomStart && dy > 0 && mTotalUnconsumed == 0 785 && Math.abs(dy - consumed[1]) > 0) { 786 mCircleView.setVisibility(View.GONE); 787 } 788 789 // Now let our nested parent consume the leftovers 790 final int[] parentConsumed = mParentScrollConsumed; 791 if (dispatchNestedPreScroll(dx - consumed[0], dy - consumed[1], parentConsumed, null)) { 792 consumed[0] += parentConsumed[0]; 793 consumed[1] += parentConsumed[1]; 794 } 795 } 796 797 @Override 798 public int getNestedScrollAxes() { 799 return mNestedScrollingParentHelper.getNestedScrollAxes(); 800 } 801 802 @Override 803 public void onStopNestedScroll(View target) { 804 mNestedScrollingParentHelper.onStopNestedScroll(target); 805 mNestedScrollInProgress = false; 806 // Finish the spinner for nested scrolling if we ever consumed any 807 // unconsumed nested scroll 808 if (mTotalUnconsumed > 0) { 809 finishSpinner(mTotalUnconsumed); 810 mTotalUnconsumed = 0; 811 } 812 // Dispatch up our nested parent 813 stopNestedScroll(); 814 } 815 816 @Override 817 public void onNestedScroll(final View target, final int dxConsumed, final int dyConsumed, 818 final int dxUnconsumed, final int dyUnconsumed) { 819 // Dispatch up to the nested parent first 820 dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, 821 mParentOffsetInWindow); 822 823 // This is a bit of a hack. Nested scrolling works from the bottom up, and as we are 824 // sometimes between two nested scrolling views, we need a way to be able to know when any 825 // nested scrolling parent has stopped handling events. We do that by using the 826 // 'offset in window 'functionality to see if we have been moved from the event. 827 // This is a decent indication of whether we should take over the event stream or not. 828 final int dy = dyUnconsumed + mParentOffsetInWindow[1]; 829 if (dy < 0 && !canChildScrollUp()) { 830 mTotalUnconsumed += Math.abs(dy); 831 moveSpinner(mTotalUnconsumed); 832 } 833 } 834 835 // NestedScrollingChild 836 837 @Override 838 public void setNestedScrollingEnabled(boolean enabled) { 839 mNestedScrollingChildHelper.setNestedScrollingEnabled(enabled); 840 } 841 842 @Override 843 public boolean isNestedScrollingEnabled() { 844 return mNestedScrollingChildHelper.isNestedScrollingEnabled(); 845 } 846 847 @Override 848 public boolean startNestedScroll(int axes) { 849 return mNestedScrollingChildHelper.startNestedScroll(axes); 850 } 851 852 @Override 853 public void stopNestedScroll() { 854 mNestedScrollingChildHelper.stopNestedScroll(); 855 } 856 857 @Override 858 public boolean hasNestedScrollingParent() { 859 return mNestedScrollingChildHelper.hasNestedScrollingParent(); 860 } 861 862 @Override 863 public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, 864 int dyUnconsumed, int[] offsetInWindow) { 865 return mNestedScrollingChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, 866 dxUnconsumed, dyUnconsumed, offsetInWindow); 867 } 868 869 @Override 870 public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) { 871 return mNestedScrollingChildHelper.dispatchNestedPreScroll( 872 dx, dy, consumed, offsetInWindow); 873 } 874 875 @Override 876 public boolean onNestedPreFling(View target, float velocityX, 877 float velocityY) { 878 return dispatchNestedPreFling(velocityX, velocityY); 879 } 880 881 @Override 882 public boolean onNestedFling(View target, float velocityX, float velocityY, 883 boolean consumed) { 884 return dispatchNestedFling(velocityX, velocityY, consumed); 885 } 886 887 @Override 888 public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) { 889 return mNestedScrollingChildHelper.dispatchNestedFling(velocityX, velocityY, consumed); 890 } 891 892 @Override 893 public boolean dispatchNestedPreFling(float velocityX, float velocityY) { 894 return mNestedScrollingChildHelper.dispatchNestedPreFling(velocityX, velocityY); 895 } 896 897 private boolean isAnimationRunning(Animation animation) { 898 return animation != null && animation.hasStarted() && !animation.hasEnded(); 899 } 900 901 @SuppressLint("NewApi") 902 private void moveSpinner(float overscrollTop) { 903 mProgress.showArrow(true); 904 float originalDragPercent = overscrollTop / mTotalDragDistance; 905 906 float dragPercent = Math.min(1f, Math.abs(originalDragPercent)); 907 float adjustedPercent = (float) Math.max(dragPercent - .4, 0) * 5 / 3; 908 float extraOS = Math.abs(overscrollTop) - mTotalDragDistance; 909 float slingshotDist = mUsingCustomStart ? mSpinnerOffsetEnd - mOriginalOffsetTop 910 : mSpinnerOffsetEnd; 911 float tensionSlingshotPercent = Math.max(0, Math.min(extraOS, slingshotDist * 2) 912 / slingshotDist); 913 float tensionPercent = (float) ((tensionSlingshotPercent / 4) - Math.pow( 914 (tensionSlingshotPercent / 4), 2)) * 2f; 915 float extraMove = (slingshotDist) * tensionPercent * 2; 916 917 int targetY = mOriginalOffsetTop + (int) ((slingshotDist * dragPercent) + extraMove); 918 // where 1.0f is a full circle 919 if (mCircleView.getVisibility() != View.VISIBLE) { 920 mCircleView.setVisibility(View.VISIBLE); 921 } 922 if (!mScale) { 923 mCircleView.setScaleX(1f); 924 mCircleView.setScaleY(1f); 925 } 926 927 if (mScale) { 928 setAnimationProgress(Math.min(1f, overscrollTop / mTotalDragDistance)); 929 } 930 if (overscrollTop < mTotalDragDistance) { 931 if (mProgress.getAlpha() > STARTING_PROGRESS_ALPHA 932 && !isAnimationRunning(mAlphaStartAnimation)) { 933 // Animate the alpha 934 startProgressAlphaStartAnimation(); 935 } 936 } else { 937 if (mProgress.getAlpha() < MAX_ALPHA && !isAnimationRunning(mAlphaMaxAnimation)) { 938 // Animate the alpha 939 startProgressAlphaMaxAnimation(); 940 } 941 } 942 float strokeStart = adjustedPercent * .8f; 943 mProgress.setStartEndTrim(0f, Math.min(MAX_PROGRESS_ANGLE, strokeStart)); 944 mProgress.setArrowScale(Math.min(1f, adjustedPercent)); 945 946 float rotation = (-0.25f + .4f * adjustedPercent + tensionPercent * 2) * .5f; 947 mProgress.setProgressRotation(rotation); 948 setTargetOffsetTopAndBottom(targetY - mCurrentTargetOffsetTop); 949 } 950 951 private void finishSpinner(float overscrollTop) { 952 if (overscrollTop > mTotalDragDistance) { 953 setRefreshing(true, true /* notify */); 954 } else { 955 // cancel refresh 956 mRefreshing = false; 957 mProgress.setStartEndTrim(0f, 0f); 958 Animation.AnimationListener listener = null; 959 if (!mScale) { 960 listener = new Animation.AnimationListener() { 961 962 @Override 963 public void onAnimationStart(Animation animation) { 964 } 965 966 @Override 967 public void onAnimationEnd(Animation animation) { 968 if (!mScale) { 969 startScaleDownAnimation(null); 970 } 971 } 972 973 @Override 974 public void onAnimationRepeat(Animation animation) { 975 } 976 977 }; 978 } 979 animateOffsetToStartPosition(mCurrentTargetOffsetTop, listener); 980 mProgress.showArrow(false); 981 } 982 } 983 984 @Override 985 public boolean onTouchEvent(MotionEvent ev) { 986 final int action = ev.getActionMasked(); 987 int pointerIndex = -1; 988 989 if (mReturningToStart && action == MotionEvent.ACTION_DOWN) { 990 mReturningToStart = false; 991 } 992 993 if (!isEnabled() || mReturningToStart || canChildScrollUp() 994 || mRefreshing || mNestedScrollInProgress) { 995 // Fail fast if we're not in a state where a swipe is possible 996 return false; 997 } 998 999 switch (action) { 1000 case MotionEvent.ACTION_DOWN: 1001 mActivePointerId = ev.getPointerId(0); 1002 mIsBeingDragged = false; 1003 break; 1004 1005 case MotionEvent.ACTION_MOVE: { 1006 pointerIndex = ev.findPointerIndex(mActivePointerId); 1007 if (pointerIndex < 0) { 1008 Log.e(LOG_TAG, "Got ACTION_MOVE event but have an invalid active pointer id."); 1009 return false; 1010 } 1011 1012 final float y = ev.getY(pointerIndex); 1013 startDragging(y); 1014 1015 if (mIsBeingDragged) { 1016 final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE; 1017 if (overscrollTop > 0) { 1018 moveSpinner(overscrollTop); 1019 } else { 1020 return false; 1021 } 1022 } 1023 break; 1024 } 1025 case MotionEvent.ACTION_POINTER_DOWN: { 1026 pointerIndex = ev.getActionIndex(); 1027 if (pointerIndex < 0) { 1028 Log.e(LOG_TAG, 1029 "Got ACTION_POINTER_DOWN event but have an invalid action index."); 1030 return false; 1031 } 1032 mActivePointerId = ev.getPointerId(pointerIndex); 1033 break; 1034 } 1035 1036 case MotionEvent.ACTION_POINTER_UP: 1037 onSecondaryPointerUp(ev); 1038 break; 1039 1040 case MotionEvent.ACTION_UP: { 1041 pointerIndex = ev.findPointerIndex(mActivePointerId); 1042 if (pointerIndex < 0) { 1043 Log.e(LOG_TAG, "Got ACTION_UP event but don't have an active pointer id."); 1044 return false; 1045 } 1046 1047 if (mIsBeingDragged) { 1048 final float y = ev.getY(pointerIndex); 1049 final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE; 1050 mIsBeingDragged = false; 1051 finishSpinner(overscrollTop); 1052 } 1053 mActivePointerId = INVALID_POINTER; 1054 return false; 1055 } 1056 case MotionEvent.ACTION_CANCEL: 1057 return false; 1058 } 1059 1060 return true; 1061 } 1062 1063 @SuppressLint("NewApi") 1064 private void startDragging(float y) { 1065 final float yDiff = y - mInitialDownY; 1066 if (yDiff > mTouchSlop && !mIsBeingDragged) { 1067 mInitialMotionY = mInitialDownY + mTouchSlop; 1068 mIsBeingDragged = true; 1069 mProgress.setAlpha(STARTING_PROGRESS_ALPHA); 1070 } 1071 } 1072 1073 private void animateOffsetToCorrectPosition(int from, AnimationListener listener) { 1074 mFrom = from; 1075 mAnimateToCorrectPosition.reset(); 1076 mAnimateToCorrectPosition.setDuration(ANIMATE_TO_TRIGGER_DURATION); 1077 mAnimateToCorrectPosition.setInterpolator(mDecelerateInterpolator); 1078 if (listener != null) { 1079 mCircleView.setAnimationListener(listener); 1080 } 1081 mCircleView.clearAnimation(); 1082 mCircleView.startAnimation(mAnimateToCorrectPosition); 1083 } 1084 1085 private void animateOffsetToStartPosition(int from, AnimationListener listener) { 1086 if (mScale) { 1087 // Scale the item back down 1088 startScaleDownReturnToStartAnimation(from, listener); 1089 } else { 1090 mFrom = from; 1091 mAnimateToStartPosition.reset(); 1092 mAnimateToStartPosition.setDuration(ANIMATE_TO_START_DURATION); 1093 mAnimateToStartPosition.setInterpolator(mDecelerateInterpolator); 1094 if (listener != null) { 1095 mCircleView.setAnimationListener(listener); 1096 } 1097 mCircleView.clearAnimation(); 1098 mCircleView.startAnimation(mAnimateToStartPosition); 1099 } 1100 } 1101 1102 private final Animation mAnimateToCorrectPosition = new Animation() { 1103 @Override 1104 public void applyTransformation(float interpolatedTime, Transformation t) { 1105 int targetTop = 0; 1106 int endTarget = 0; 1107 if (!mUsingCustomStart) { 1108 endTarget = mSpinnerOffsetEnd - Math.abs(mOriginalOffsetTop); 1109 } else { 1110 endTarget = mSpinnerOffsetEnd; 1111 } 1112 targetTop = (mFrom + (int) ((endTarget - mFrom) * interpolatedTime)); 1113 int offset = targetTop - mCircleView.getTop(); 1114 setTargetOffsetTopAndBottom(offset); 1115 mProgress.setArrowScale(1 - interpolatedTime); 1116 } 1117 }; 1118 1119 void moveToStart(float interpolatedTime) { 1120 int targetTop = 0; 1121 targetTop = (mFrom + (int) ((mOriginalOffsetTop - mFrom) * interpolatedTime)); 1122 int offset = targetTop - mCircleView.getTop(); 1123 setTargetOffsetTopAndBottom(offset); 1124 } 1125 1126 private final Animation mAnimateToStartPosition = new Animation() { 1127 @Override 1128 public void applyTransformation(float interpolatedTime, Transformation t) { 1129 moveToStart(interpolatedTime); 1130 } 1131 }; 1132 1133 @SuppressLint("NewApi") 1134 private void startScaleDownReturnToStartAnimation(int from, 1135 Animation.AnimationListener listener) { 1136 mFrom = from; 1137 mStartingScale = mCircleView.getScaleX(); 1138 mScaleDownToStartAnimation = new Animation() { 1139 @Override 1140 public void applyTransformation(float interpolatedTime, Transformation t) { 1141 float targetScale = (mStartingScale + (-mStartingScale * interpolatedTime)); 1142 setAnimationProgress(targetScale); 1143 moveToStart(interpolatedTime); 1144 } 1145 }; 1146 mScaleDownToStartAnimation.setDuration(SCALE_DOWN_DURATION); 1147 if (listener != null) { 1148 mCircleView.setAnimationListener(listener); 1149 } 1150 mCircleView.clearAnimation(); 1151 mCircleView.startAnimation(mScaleDownToStartAnimation); 1152 } 1153 1154 void setTargetOffsetTopAndBottom(int offset) { 1155 mCircleView.bringToFront(); 1156 ViewCompat.offsetTopAndBottom(mCircleView, offset); 1157 mCurrentTargetOffsetTop = mCircleView.getTop(); 1158 } 1159 1160 private void onSecondaryPointerUp(MotionEvent ev) { 1161 final int pointerIndex = ev.getActionIndex(); 1162 final int pointerId = ev.getPointerId(pointerIndex); 1163 if (pointerId == mActivePointerId) { 1164 // This was our active pointer going up. Choose a new 1165 // active pointer and adjust accordingly. 1166 final int newPointerIndex = pointerIndex == 0 ? 1 : 0; 1167 mActivePointerId = ev.getPointerId(newPointerIndex); 1168 } 1169 } 1170 1171 /** 1172 * Classes that wish to be notified when the swipe gesture correctly 1173 * triggers a refresh should implement this interface. 1174 */ 1175 public interface OnRefreshListener { 1176 /** 1177 * Called when a swipe gesture triggers a refresh. 1178 */ 1179 void onRefresh(); 1180 } 1181 1182 /** 1183 * Classes that wish to override {@link SwipeRefreshLayout#canChildScrollUp()} method 1184 * behavior should implement this interface. 1185 */ 1186 public interface OnChildScrollUpCallback { 1187 /** 1188 * Callback that will be called when {@link SwipeRefreshLayout#canChildScrollUp()} method 1189 * is called to allow the implementer to override its behavior. 1190 * 1191 * @param parent SwipeRefreshLayout that this callback is overriding. 1192 * @param child The child view of SwipeRefreshLayout. 1193 * 1194 * @return Whether it is possible for the child view of parent layout to scroll up. 1195 */ 1196 boolean canChildScrollUp(SwipeRefreshLayout parent, @Nullable View child); 1197 } 1198} 1199