SwipeRefreshLayout.java revision 7043bbcdb8a91e97fce29de32679d35ba4a7a85e
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.TypedArray; 21import android.support.annotation.ColorInt; 22import android.support.annotation.ColorRes; 23import android.support.annotation.Nullable; 24import android.support.annotation.VisibleForTesting; 25import android.support.v4.content.ContextCompat; 26import android.support.v4.view.NestedScrollingChild; 27import android.support.v4.view.NestedScrollingChildHelper; 28import android.support.v4.view.NestedScrollingParent; 29import android.support.v4.view.NestedScrollingParentHelper; 30import android.support.v4.view.ViewCompat; 31import android.util.AttributeSet; 32import android.util.DisplayMetrics; 33import android.util.Log; 34import android.view.MotionEvent; 35import android.view.View; 36import android.view.ViewConfiguration; 37import android.view.ViewGroup; 38import android.view.animation.Animation; 39import android.view.animation.Animation.AnimationListener; 40import android.view.animation.DecelerateInterpolator; 41import android.view.animation.Transformation; 42import android.widget.AbsListView; 43import android.widget.ListView; 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 @Override 180 public void onAnimationEnd(Animation animation) { 181 if (mRefreshing) { 182 // Make sure the progress view is fully visible 183 mProgress.setAlpha(MAX_ALPHA); 184 mProgress.start(); 185 if (mNotify) { 186 if (mListener != null) { 187 mListener.onRefresh(); 188 } 189 } 190 mCurrentTargetOffsetTop = mCircleView.getTop(); 191 } else { 192 reset(); 193 } 194 } 195 }; 196 197 void reset() { 198 mCircleView.clearAnimation(); 199 mProgress.stop(); 200 mCircleView.setVisibility(View.GONE); 201 setColorViewAlpha(MAX_ALPHA); 202 // Return the circle to its start position 203 if (mScale) { 204 setAnimationProgress(0 /* animation complete and view is hidden */); 205 } else { 206 setTargetOffsetTopAndBottom(mOriginalOffsetTop - mCurrentTargetOffsetTop); 207 } 208 mCurrentTargetOffsetTop = mCircleView.getTop(); 209 } 210 211 @Override 212 public void setEnabled(boolean enabled) { 213 super.setEnabled(enabled); 214 if (!enabled) { 215 reset(); 216 } 217 } 218 219 @Override 220 protected void onDetachedFromWindow() { 221 super.onDetachedFromWindow(); 222 reset(); 223 } 224 225 private void setColorViewAlpha(int targetAlpha) { 226 mCircleView.getBackground().setAlpha(targetAlpha); 227 mProgress.setAlpha(targetAlpha); 228 } 229 230 /** 231 * The refresh indicator starting and resting position is always positioned 232 * near the top of the refreshing content. This position is a consistent 233 * location, but can be adjusted in either direction based on whether or not 234 * there is a toolbar or actionbar present. 235 * <p> 236 * <strong>Note:</strong> Calling this will reset the position of the refresh indicator to 237 * <code>start</code>. 238 * </p> 239 * 240 * @param scale Set to true if there is no view at a higher z-order than where the progress 241 * spinner is set to appear. Setting it to true will cause indicator to be scaled 242 * up rather than clipped. 243 * @param start The offset in pixels from the top of this view at which the 244 * progress spinner should appear. 245 * @param end The offset in pixels from the top of this view at which the 246 * progress spinner should come to rest after a successful swipe 247 * gesture. 248 */ 249 public void setProgressViewOffset(boolean scale, int start, int end) { 250 mScale = scale; 251 mOriginalOffsetTop = start; 252 mSpinnerOffsetEnd = end; 253 mUsingCustomStart = true; 254 reset(); 255 mRefreshing = false; 256 } 257 258 /** 259 * @return The offset in pixels from the top of this view at which the progress spinner should 260 * appear. 261 */ 262 public int getProgressViewStartOffset() { 263 return mOriginalOffsetTop; 264 } 265 266 /** 267 * @return The offset in pixels from the top of this view at which the progress spinner should 268 * come to rest after a successful swipe gesture. 269 */ 270 public int getProgressViewEndOffset() { 271 return mSpinnerOffsetEnd; 272 } 273 274 /** 275 * The refresh indicator resting position is always positioned near the top 276 * of the refreshing content. This position is a consistent location, but 277 * can be adjusted in either direction based on whether or not there is a 278 * toolbar or actionbar present. 279 * 280 * @param scale Set to true if there is no view at a higher z-order than where the progress 281 * spinner is set to appear. Setting it to true will cause indicator to be scaled 282 * up rather than clipped. 283 * @param end The offset in pixels from the top of this view at which the 284 * progress spinner should come to rest after a successful swipe 285 * gesture. 286 */ 287 public void setProgressViewEndTarget(boolean scale, int end) { 288 mSpinnerOffsetEnd = end; 289 mScale = scale; 290 mCircleView.invalidate(); 291 } 292 293 /** 294 * One of DEFAULT, or LARGE. 295 */ 296 public void setSize(int size) { 297 if (size != MaterialProgressDrawable.LARGE && size != MaterialProgressDrawable.DEFAULT) { 298 return; 299 } 300 final DisplayMetrics metrics = getResources().getDisplayMetrics(); 301 if (size == MaterialProgressDrawable.LARGE) { 302 mCircleDiameter = (int) (CIRCLE_DIAMETER_LARGE * metrics.density); 303 } else { 304 mCircleDiameter = (int) (CIRCLE_DIAMETER * metrics.density); 305 } 306 // force the bounds of the progress circle inside the circle view to 307 // update by setting it to null before updating its size and then 308 // re-setting it 309 mCircleView.setImageDrawable(null); 310 mProgress.updateSizes(size); 311 mCircleView.setImageDrawable(mProgress); 312 } 313 314 /** 315 * Simple constructor to use when creating a SwipeRefreshLayout from code. 316 * 317 * @param context 318 */ 319 public SwipeRefreshLayout(Context context) { 320 this(context, null); 321 } 322 323 /** 324 * Constructor that is called when inflating SwipeRefreshLayout from XML. 325 * 326 * @param context 327 * @param attrs 328 */ 329 public SwipeRefreshLayout(Context context, AttributeSet attrs) { 330 super(context, attrs); 331 332 mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); 333 334 mMediumAnimationDuration = getResources().getInteger( 335 android.R.integer.config_mediumAnimTime); 336 337 setWillNotDraw(false); 338 mDecelerateInterpolator = new DecelerateInterpolator(DECELERATE_INTERPOLATION_FACTOR); 339 340 final DisplayMetrics metrics = getResources().getDisplayMetrics(); 341 mCircleDiameter = (int) (CIRCLE_DIAMETER * metrics.density); 342 343 createProgressView(); 344 ViewCompat.setChildrenDrawingOrderEnabled(this, true); 345 // the absolute offset has to take into account that the circle starts at an offset 346 mSpinnerOffsetEnd = (int) (DEFAULT_CIRCLE_TARGET * metrics.density); 347 mTotalDragDistance = mSpinnerOffsetEnd; 348 mNestedScrollingParentHelper = new NestedScrollingParentHelper(this); 349 350 mNestedScrollingChildHelper = new NestedScrollingChildHelper(this); 351 setNestedScrollingEnabled(true); 352 353 mOriginalOffsetTop = mCurrentTargetOffsetTop = -mCircleDiameter; 354 moveToStart(1.0f); 355 356 final TypedArray a = context.obtainStyledAttributes(attrs, LAYOUT_ATTRS); 357 setEnabled(a.getBoolean(0, true)); 358 a.recycle(); 359 } 360 361 @Override 362 protected int getChildDrawingOrder(int childCount, int i) { 363 if (mCircleViewIndex < 0) { 364 return i; 365 } else if (i == childCount - 1) { 366 // Draw the selected child last 367 return mCircleViewIndex; 368 } else if (i >= mCircleViewIndex) { 369 // Move the children after the selected child earlier one 370 return i + 1; 371 } else { 372 // Keep the children before the selected child the same 373 return i; 374 } 375 } 376 377 private void createProgressView() { 378 mCircleView = new CircleImageView(getContext(), CIRCLE_BG_LIGHT); 379 mProgress = new MaterialProgressDrawable(getContext(), this); 380 mProgress.setBackgroundColor(CIRCLE_BG_LIGHT); 381 mCircleView.setImageDrawable(mProgress); 382 mCircleView.setVisibility(View.GONE); 383 addView(mCircleView); 384 } 385 386 /** 387 * Set the listener to be notified when a refresh is triggered via the swipe 388 * gesture. 389 */ 390 public void setOnRefreshListener(OnRefreshListener listener) { 391 mListener = listener; 392 } 393 394 /** 395 * Notify the widget that refresh state has changed. Do not call this when 396 * refresh is triggered by a swipe gesture. 397 * 398 * @param refreshing Whether or not the view should show refresh progress. 399 */ 400 public void setRefreshing(boolean refreshing) { 401 if (refreshing && mRefreshing != refreshing) { 402 // scale and show 403 mRefreshing = refreshing; 404 int endTarget = 0; 405 if (!mUsingCustomStart) { 406 endTarget = mSpinnerOffsetEnd + mOriginalOffsetTop; 407 } else { 408 endTarget = mSpinnerOffsetEnd; 409 } 410 setTargetOffsetTopAndBottom(endTarget - mCurrentTargetOffsetTop); 411 mNotify = false; 412 startScaleUpAnimation(mRefreshListener); 413 } else { 414 setRefreshing(refreshing, false /* notify */); 415 } 416 } 417 418 private void startScaleUpAnimation(AnimationListener listener) { 419 mCircleView.setVisibility(View.VISIBLE); 420 if (android.os.Build.VERSION.SDK_INT >= 11) { 421 // Pre API 11, alpha is used in place of scale up to show the 422 // progress circle appearing. 423 // Don't adjust the alpha during appearance otherwise. 424 mProgress.setAlpha(MAX_ALPHA); 425 } 426 mScaleAnimation = new Animation() { 427 @Override 428 public void applyTransformation(float interpolatedTime, Transformation t) { 429 setAnimationProgress(interpolatedTime); 430 } 431 }; 432 mScaleAnimation.setDuration(mMediumAnimationDuration); 433 if (listener != null) { 434 mCircleView.setAnimationListener(listener); 435 } 436 mCircleView.clearAnimation(); 437 mCircleView.startAnimation(mScaleAnimation); 438 } 439 440 /** 441 * Pre API 11, this does an alpha animation. 442 * @param progress 443 */ 444 void setAnimationProgress(float progress) { 445 mCircleView.setScaleX(progress); 446 mCircleView.setScaleY(progress); 447 } 448 449 private void setRefreshing(boolean refreshing, final boolean notify) { 450 if (mRefreshing != refreshing) { 451 mNotify = notify; 452 ensureTarget(); 453 mRefreshing = refreshing; 454 if (mRefreshing) { 455 animateOffsetToCorrectPosition(mCurrentTargetOffsetTop, mRefreshListener); 456 } else { 457 startScaleDownAnimation(mRefreshListener); 458 } 459 } 460 } 461 462 void startScaleDownAnimation(Animation.AnimationListener listener) { 463 mScaleDownAnimation = new Animation() { 464 @Override 465 public void applyTransformation(float interpolatedTime, Transformation t) { 466 setAnimationProgress(1 - interpolatedTime); 467 } 468 }; 469 mScaleDownAnimation.setDuration(SCALE_DOWN_DURATION); 470 mCircleView.setAnimationListener(listener); 471 mCircleView.clearAnimation(); 472 mCircleView.startAnimation(mScaleDownAnimation); 473 } 474 475 private void startProgressAlphaStartAnimation() { 476 mAlphaStartAnimation = startAlphaAnimation(mProgress.getAlpha(), STARTING_PROGRESS_ALPHA); 477 } 478 479 private void startProgressAlphaMaxAnimation() { 480 mAlphaMaxAnimation = startAlphaAnimation(mProgress.getAlpha(), MAX_ALPHA); 481 } 482 483 private Animation startAlphaAnimation(final int startingAlpha, final int endingAlpha) { 484 Animation alpha = new Animation() { 485 @Override 486 public void applyTransformation(float interpolatedTime, Transformation t) { 487 mProgress.setAlpha( 488 (int) (startingAlpha + ((endingAlpha - startingAlpha) * interpolatedTime))); 489 } 490 }; 491 alpha.setDuration(ALPHA_ANIMATION_DURATION); 492 // Clear out the previous animation listeners. 493 mCircleView.setAnimationListener(null); 494 mCircleView.clearAnimation(); 495 mCircleView.startAnimation(alpha); 496 return alpha; 497 } 498 499 /** 500 * @deprecated Use {@link #setProgressBackgroundColorSchemeResource(int)} 501 */ 502 @Deprecated 503 public void setProgressBackgroundColor(int colorRes) { 504 setProgressBackgroundColorSchemeResource(colorRes); 505 } 506 507 /** 508 * Set the background color of the progress spinner disc. 509 * 510 * @param colorRes Resource id of the color. 511 */ 512 public void setProgressBackgroundColorSchemeResource(@ColorRes int colorRes) { 513 setProgressBackgroundColorSchemeColor(ContextCompat.getColor(getContext(), colorRes)); 514 } 515 516 /** 517 * Set the background color of the progress spinner disc. 518 * 519 * @param color 520 */ 521 public void setProgressBackgroundColorSchemeColor(@ColorInt int color) { 522 mCircleView.setBackgroundColor(color); 523 mProgress.setBackgroundColor(color); 524 } 525 526 /** 527 * @deprecated Use {@link #setColorSchemeResources(int...)} 528 */ 529 @Deprecated 530 public void setColorScheme(@ColorInt int... colors) { 531 setColorSchemeResources(colors); 532 } 533 534 /** 535 * Set the color resources used in the progress animation from color resources. 536 * The first color will also be the color of the bar that grows in response 537 * to a user swipe gesture. 538 * 539 * @param colorResIds 540 */ 541 public void setColorSchemeResources(@ColorRes int... colorResIds) { 542 final Context context = getContext(); 543 int[] colorRes = new int[colorResIds.length]; 544 for (int i = 0; i < colorResIds.length; i++) { 545 colorRes[i] = ContextCompat.getColor(context, colorResIds[i]); 546 } 547 setColorSchemeColors(colorRes); 548 } 549 550 /** 551 * Set the colors used in the progress animation. The first 552 * color will also be the color of the bar that grows in response to a user 553 * swipe gesture. 554 * 555 * @param colors 556 */ 557 public void setColorSchemeColors(@ColorInt int... colors) { 558 ensureTarget(); 559 mProgress.setColorSchemeColors(colors); 560 } 561 562 /** 563 * @return Whether the SwipeRefreshWidget is actively showing refresh 564 * progress. 565 */ 566 public boolean isRefreshing() { 567 return mRefreshing; 568 } 569 570 private void ensureTarget() { 571 // Don't bother getting the parent height if the parent hasn't been laid 572 // out yet. 573 if (mTarget == null) { 574 for (int i = 0; i < getChildCount(); i++) { 575 View child = getChildAt(i); 576 if (!child.equals(mCircleView)) { 577 mTarget = child; 578 break; 579 } 580 } 581 } 582 } 583 584 /** 585 * Set the distance to trigger a sync in dips 586 * 587 * @param distance 588 */ 589 public void setDistanceToTriggerSync(int distance) { 590 mTotalDragDistance = distance; 591 } 592 593 @Override 594 protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 595 final int width = getMeasuredWidth(); 596 final int height = getMeasuredHeight(); 597 if (getChildCount() == 0) { 598 return; 599 } 600 if (mTarget == null) { 601 ensureTarget(); 602 } 603 if (mTarget == null) { 604 return; 605 } 606 final View child = mTarget; 607 final int childLeft = getPaddingLeft(); 608 final int childTop = getPaddingTop(); 609 final int childWidth = width - getPaddingLeft() - getPaddingRight(); 610 final int childHeight = height - getPaddingTop() - getPaddingBottom(); 611 child.layout(childLeft, childTop, childLeft + childWidth, childTop + childHeight); 612 int circleWidth = mCircleView.getMeasuredWidth(); 613 int circleHeight = mCircleView.getMeasuredHeight(); 614 mCircleView.layout((width / 2 - circleWidth / 2), mCurrentTargetOffsetTop, 615 (width / 2 + circleWidth / 2), mCurrentTargetOffsetTop + circleHeight); 616 } 617 618 @Override 619 public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 620 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 621 if (mTarget == null) { 622 ensureTarget(); 623 } 624 if (mTarget == null) { 625 return; 626 } 627 mTarget.measure(MeasureSpec.makeMeasureSpec( 628 getMeasuredWidth() - getPaddingLeft() - getPaddingRight(), 629 MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec( 630 getMeasuredHeight() - getPaddingTop() - getPaddingBottom(), MeasureSpec.EXACTLY)); 631 mCircleView.measure(MeasureSpec.makeMeasureSpec(mCircleDiameter, MeasureSpec.EXACTLY), 632 MeasureSpec.makeMeasureSpec(mCircleDiameter, MeasureSpec.EXACTLY)); 633 mCircleViewIndex = -1; 634 // Get the index of the circleview. 635 for (int index = 0; index < getChildCount(); index++) { 636 if (getChildAt(index) == mCircleView) { 637 mCircleViewIndex = index; 638 break; 639 } 640 } 641 } 642 643 /** 644 * Get the diameter of the progress circle that is displayed as part of the 645 * swipe to refresh layout. 646 * 647 * @return Diameter in pixels of the progress circle view. 648 */ 649 public int getProgressCircleDiameter() { 650 return mCircleDiameter; 651 } 652 653 /** 654 * @return Whether it is possible for the child view of this layout to 655 * scroll up. Override this if the child view is a custom view. 656 */ 657 public boolean canChildScrollUp() { 658 if (mChildScrollUpCallback != null) { 659 return mChildScrollUpCallback.canChildScrollUp(this, mTarget); 660 } 661 if (mTarget instanceof ListView) { 662 return ListViewCompat.canScrollList((ListView) mTarget, -1); 663 } 664 return mTarget.canScrollVertically(-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 private void moveSpinner(float overscrollTop) { 902 mProgress.showArrow(true); 903 float originalDragPercent = overscrollTop / mTotalDragDistance; 904 905 float dragPercent = Math.min(1f, Math.abs(originalDragPercent)); 906 float adjustedPercent = (float) Math.max(dragPercent - .4, 0) * 5 / 3; 907 float extraOS = Math.abs(overscrollTop) - mTotalDragDistance; 908 float slingshotDist = mUsingCustomStart ? mSpinnerOffsetEnd - mOriginalOffsetTop 909 : mSpinnerOffsetEnd; 910 float tensionSlingshotPercent = Math.max(0, Math.min(extraOS, slingshotDist * 2) 911 / slingshotDist); 912 float tensionPercent = (float) ((tensionSlingshotPercent / 4) - Math.pow( 913 (tensionSlingshotPercent / 4), 2)) * 2f; 914 float extraMove = (slingshotDist) * tensionPercent * 2; 915 916 int targetY = mOriginalOffsetTop + (int) ((slingshotDist * dragPercent) + extraMove); 917 // where 1.0f is a full circle 918 if (mCircleView.getVisibility() != View.VISIBLE) { 919 mCircleView.setVisibility(View.VISIBLE); 920 } 921 if (!mScale) { 922 mCircleView.setScaleX(1f); 923 mCircleView.setScaleY(1f); 924 } 925 926 if (mScale) { 927 setAnimationProgress(Math.min(1f, overscrollTop / mTotalDragDistance)); 928 } 929 if (overscrollTop < mTotalDragDistance) { 930 if (mProgress.getAlpha() > STARTING_PROGRESS_ALPHA 931 && !isAnimationRunning(mAlphaStartAnimation)) { 932 // Animate the alpha 933 startProgressAlphaStartAnimation(); 934 } 935 } else { 936 if (mProgress.getAlpha() < MAX_ALPHA && !isAnimationRunning(mAlphaMaxAnimation)) { 937 // Animate the alpha 938 startProgressAlphaMaxAnimation(); 939 } 940 } 941 float strokeStart = adjustedPercent * .8f; 942 mProgress.setStartEndTrim(0f, Math.min(MAX_PROGRESS_ANGLE, strokeStart)); 943 mProgress.setArrowScale(Math.min(1f, adjustedPercent)); 944 945 float rotation = (-0.25f + .4f * adjustedPercent + tensionPercent * 2) * .5f; 946 mProgress.setProgressRotation(rotation); 947 setTargetOffsetTopAndBottom(targetY - mCurrentTargetOffsetTop); 948 } 949 950 private void finishSpinner(float overscrollTop) { 951 if (overscrollTop > mTotalDragDistance) { 952 setRefreshing(true, true /* notify */); 953 } else { 954 // cancel refresh 955 mRefreshing = false; 956 mProgress.setStartEndTrim(0f, 0f); 957 Animation.AnimationListener listener = null; 958 if (!mScale) { 959 listener = new Animation.AnimationListener() { 960 961 @Override 962 public void onAnimationStart(Animation animation) { 963 } 964 965 @Override 966 public void onAnimationEnd(Animation animation) { 967 if (!mScale) { 968 startScaleDownAnimation(null); 969 } 970 } 971 972 @Override 973 public void onAnimationRepeat(Animation animation) { 974 } 975 976 }; 977 } 978 animateOffsetToStartPosition(mCurrentTargetOffsetTop, listener); 979 mProgress.showArrow(false); 980 } 981 } 982 983 @Override 984 public boolean onTouchEvent(MotionEvent ev) { 985 final int action = ev.getActionMasked(); 986 int pointerIndex = -1; 987 988 if (mReturningToStart && action == MotionEvent.ACTION_DOWN) { 989 mReturningToStart = false; 990 } 991 992 if (!isEnabled() || mReturningToStart || canChildScrollUp() 993 || mRefreshing || mNestedScrollInProgress) { 994 // Fail fast if we're not in a state where a swipe is possible 995 return false; 996 } 997 998 switch (action) { 999 case MotionEvent.ACTION_DOWN: 1000 mActivePointerId = ev.getPointerId(0); 1001 mIsBeingDragged = false; 1002 break; 1003 1004 case MotionEvent.ACTION_MOVE: { 1005 pointerIndex = ev.findPointerIndex(mActivePointerId); 1006 if (pointerIndex < 0) { 1007 Log.e(LOG_TAG, "Got ACTION_MOVE event but have an invalid active pointer id."); 1008 return false; 1009 } 1010 1011 final float y = ev.getY(pointerIndex); 1012 startDragging(y); 1013 1014 if (mIsBeingDragged) { 1015 final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE; 1016 if (overscrollTop > 0) { 1017 moveSpinner(overscrollTop); 1018 } else { 1019 return false; 1020 } 1021 } 1022 break; 1023 } 1024 case MotionEvent.ACTION_POINTER_DOWN: { 1025 pointerIndex = ev.getActionIndex(); 1026 if (pointerIndex < 0) { 1027 Log.e(LOG_TAG, 1028 "Got ACTION_POINTER_DOWN event but have an invalid action index."); 1029 return false; 1030 } 1031 mActivePointerId = ev.getPointerId(pointerIndex); 1032 break; 1033 } 1034 1035 case MotionEvent.ACTION_POINTER_UP: 1036 onSecondaryPointerUp(ev); 1037 break; 1038 1039 case MotionEvent.ACTION_UP: { 1040 pointerIndex = ev.findPointerIndex(mActivePointerId); 1041 if (pointerIndex < 0) { 1042 Log.e(LOG_TAG, "Got ACTION_UP event but don't have an active pointer id."); 1043 return false; 1044 } 1045 1046 if (mIsBeingDragged) { 1047 final float y = ev.getY(pointerIndex); 1048 final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE; 1049 mIsBeingDragged = false; 1050 finishSpinner(overscrollTop); 1051 } 1052 mActivePointerId = INVALID_POINTER; 1053 return false; 1054 } 1055 case MotionEvent.ACTION_CANCEL: 1056 return false; 1057 } 1058 1059 return true; 1060 } 1061 1062 private void startDragging(float y) { 1063 final float yDiff = y - mInitialDownY; 1064 if (yDiff > mTouchSlop && !mIsBeingDragged) { 1065 mInitialMotionY = mInitialDownY + mTouchSlop; 1066 mIsBeingDragged = true; 1067 mProgress.setAlpha(STARTING_PROGRESS_ALPHA); 1068 } 1069 } 1070 1071 private void animateOffsetToCorrectPosition(int from, AnimationListener listener) { 1072 mFrom = from; 1073 mAnimateToCorrectPosition.reset(); 1074 mAnimateToCorrectPosition.setDuration(ANIMATE_TO_TRIGGER_DURATION); 1075 mAnimateToCorrectPosition.setInterpolator(mDecelerateInterpolator); 1076 if (listener != null) { 1077 mCircleView.setAnimationListener(listener); 1078 } 1079 mCircleView.clearAnimation(); 1080 mCircleView.startAnimation(mAnimateToCorrectPosition); 1081 } 1082 1083 private void animateOffsetToStartPosition(int from, AnimationListener listener) { 1084 if (mScale) { 1085 // Scale the item back down 1086 startScaleDownReturnToStartAnimation(from, listener); 1087 } else { 1088 mFrom = from; 1089 mAnimateToStartPosition.reset(); 1090 mAnimateToStartPosition.setDuration(ANIMATE_TO_START_DURATION); 1091 mAnimateToStartPosition.setInterpolator(mDecelerateInterpolator); 1092 if (listener != null) { 1093 mCircleView.setAnimationListener(listener); 1094 } 1095 mCircleView.clearAnimation(); 1096 mCircleView.startAnimation(mAnimateToStartPosition); 1097 } 1098 } 1099 1100 private final Animation mAnimateToCorrectPosition = new Animation() { 1101 @Override 1102 public void applyTransformation(float interpolatedTime, Transformation t) { 1103 int targetTop = 0; 1104 int endTarget = 0; 1105 if (!mUsingCustomStart) { 1106 endTarget = mSpinnerOffsetEnd - Math.abs(mOriginalOffsetTop); 1107 } else { 1108 endTarget = mSpinnerOffsetEnd; 1109 } 1110 targetTop = (mFrom + (int) ((endTarget - mFrom) * interpolatedTime)); 1111 int offset = targetTop - mCircleView.getTop(); 1112 setTargetOffsetTopAndBottom(offset); 1113 mProgress.setArrowScale(1 - interpolatedTime); 1114 } 1115 }; 1116 1117 void moveToStart(float interpolatedTime) { 1118 int targetTop = 0; 1119 targetTop = (mFrom + (int) ((mOriginalOffsetTop - mFrom) * interpolatedTime)); 1120 int offset = targetTop - mCircleView.getTop(); 1121 setTargetOffsetTopAndBottom(offset); 1122 } 1123 1124 private final Animation mAnimateToStartPosition = new Animation() { 1125 @Override 1126 public void applyTransformation(float interpolatedTime, Transformation t) { 1127 moveToStart(interpolatedTime); 1128 } 1129 }; 1130 1131 private void startScaleDownReturnToStartAnimation(int from, 1132 Animation.AnimationListener listener) { 1133 mFrom = from; 1134 mStartingScale = mCircleView.getScaleX(); 1135 mScaleDownToStartAnimation = new Animation() { 1136 @Override 1137 public void applyTransformation(float interpolatedTime, Transformation t) { 1138 float targetScale = (mStartingScale + (-mStartingScale * interpolatedTime)); 1139 setAnimationProgress(targetScale); 1140 moveToStart(interpolatedTime); 1141 } 1142 }; 1143 mScaleDownToStartAnimation.setDuration(SCALE_DOWN_DURATION); 1144 if (listener != null) { 1145 mCircleView.setAnimationListener(listener); 1146 } 1147 mCircleView.clearAnimation(); 1148 mCircleView.startAnimation(mScaleDownToStartAnimation); 1149 } 1150 1151 void setTargetOffsetTopAndBottom(int offset) { 1152 mCircleView.bringToFront(); 1153 ViewCompat.offsetTopAndBottom(mCircleView, offset); 1154 mCurrentTargetOffsetTop = mCircleView.getTop(); 1155 } 1156 1157 private void onSecondaryPointerUp(MotionEvent ev) { 1158 final int pointerIndex = ev.getActionIndex(); 1159 final int pointerId = ev.getPointerId(pointerIndex); 1160 if (pointerId == mActivePointerId) { 1161 // This was our active pointer going up. Choose a new 1162 // active pointer and adjust accordingly. 1163 final int newPointerIndex = pointerIndex == 0 ? 1 : 0; 1164 mActivePointerId = ev.getPointerId(newPointerIndex); 1165 } 1166 } 1167 1168 /** 1169 * Classes that wish to be notified when the swipe gesture correctly 1170 * triggers a refresh should implement this interface. 1171 */ 1172 public interface OnRefreshListener { 1173 /** 1174 * Called when a swipe gesture triggers a refresh. 1175 */ 1176 void onRefresh(); 1177 } 1178 1179 /** 1180 * Classes that wish to override {@link SwipeRefreshLayout#canChildScrollUp()} method 1181 * behavior should implement this interface. 1182 */ 1183 public interface OnChildScrollUpCallback { 1184 /** 1185 * Callback that will be called when {@link SwipeRefreshLayout#canChildScrollUp()} method 1186 * is called to allow the implementer to override its behavior. 1187 * 1188 * @param parent SwipeRefreshLayout that this callback is overriding. 1189 * @param child The child view of SwipeRefreshLayout. 1190 * 1191 * @return Whether it is possible for the child view of parent layout to scroll up. 1192 */ 1193 boolean canChildScrollUp(SwipeRefreshLayout parent, @Nullable View child); 1194 } 1195} 1196