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