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