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