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