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