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