SwipeRefreshLayout.java revision cdf346714ac98eb4f40acf33f4328eb68edfb2ef
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.v4.view.MotionEventCompat; 23import android.support.v4.view.ViewCompat; 24import android.util.AttributeSet; 25import android.util.DisplayMetrics; 26import android.util.Log; 27import android.view.MotionEvent; 28import android.view.View; 29import android.view.ViewConfiguration; 30import android.view.ViewGroup; 31import android.view.animation.Animation; 32import android.view.animation.Animation.AnimationListener; 33import android.view.animation.DecelerateInterpolator; 34import android.view.animation.Transformation; 35import android.widget.AbsListView; 36 37/** 38 * The SwipeRefreshLayout should be used whenever the user can refresh the 39 * contents of a view via a vertical swipe gesture. The activity that 40 * instantiates this view should add an OnRefreshListener to be notified 41 * whenever the swipe to refresh gesture is completed. The SwipeRefreshLayout 42 * will notify the listener each and every time the gesture is completed again; 43 * the listener is responsible for correctly determining when to actually 44 * initiate a refresh of its content. If the listener determines there should 45 * not be a refresh, it must call setRefreshing(false) to cancel any visual 46 * indication of a refresh. If an activity wishes to show just the progress 47 * animation, it should call setRefreshing(true). To disable the gesture and 48 * progress animation, call setEnabled(false) on the view. 49 * <p> 50 * This layout should be made the parent of the view that will be refreshed as a 51 * result of the gesture and can only support one direct child. This view will 52 * also be made the target of the gesture and will be forced to match both the 53 * width and the height supplied in this layout. The SwipeRefreshLayout does not 54 * provide accessibility events; instead, a menu item must be provided to allow 55 * refresh of the content wherever this gesture is used. 56 * </p> 57 */ 58public class SwipeRefreshLayout extends ViewGroup { 59 // Maps to ProgressBar.Large style 60 public static final int LARGE = MaterialProgressDrawable.LARGE; 61 // Maps to ProgressBar default style 62 public static final int DEFAULT = MaterialProgressDrawable.DEFAULT; 63 64 private static final String LOG_TAG = SwipeRefreshLayout.class.getSimpleName(); 65 66 private static final int MAX_ALPHA = 255; 67 private static final int STARTING_PROGRESS_ALPHA = (int) (.3f * MAX_ALPHA); 68 69 private static final int CIRCLE_DIAMETER = 40; 70 private static final int CIRCLE_DIAMETER_LARGE = 56; 71 72 private static final float DECELERATE_INTERPOLATION_FACTOR = 2f; 73 private static final int INVALID_POINTER = -1; 74 private static final float DRAG_RATE = .5f; 75 76 // Max amount of circle that can be filled by progress during swipe gesture, 77 // where 1.0 is a full circle 78 private static final float MAX_PROGRESS_ANGLE = .8f; 79 80 private static final int SCALE_DOWN_DURATION = 150; 81 82 private static final int ALPHA_ANIMATION_DURATION = 300; 83 84 private static final int ANIMATE_TO_TRIGGER_DURATION = 200; 85 86 private static final int ANIMATE_TO_START_DURATION = 200; 87 88 // Default background for the progress spinner 89 private static final int CIRCLE_BG_LIGHT = 0xFFFAFAFA; 90 // Default offset in dips from the top of the view to where the progress spinner should stop 91 private static final int DEFAULT_CIRCLE_TARGET = 64; 92 93 private View mTarget; // the target of the gesture 94 private OnRefreshListener mListener; 95 private boolean mRefreshing = false; 96 private int mTouchSlop; 97 private float mTotalDragDistance = -1; 98 private int mMediumAnimationDuration; 99 private int mCurrentTargetOffsetTop; 100 // Whether or not the starting offset has been determined. 101 private boolean mOriginalOffsetCalculated = false; 102 103 private float mInitialMotionY; 104 private boolean mIsBeingDragged; 105 private int mActivePointerId = INVALID_POINTER; 106 // Whether this item is scaled up rather than clipped 107 private boolean mScale; 108 109 // Target is returning to its start offset because it was cancelled or a 110 // refresh was triggered. 111 private boolean mReturningToStart; 112 private final DecelerateInterpolator mDecelerateInterpolator; 113 private static final int[] LAYOUT_ATTRS = new int[] { 114 android.R.attr.enabled 115 }; 116 117 private CircleImageView mCircleView; 118 119 protected int mFrom; 120 121 protected int mOriginalOffsetTop; 122 123 private MaterialProgressDrawable mProgress; 124 125 private Animation mScaleAnimation; 126 127 private Animation mScaleDownAnimation; 128 129 private Animation mAlphaStartAnimation; 130 131 private Animation mAlphaMaxAnimation; 132 133 private float mSpinnerFinalOffset; 134 135 private boolean mNotify; 136 137 private int mCircleWidth; 138 139 private int mCircleHeight; 140 141 // Whether the client has set a custom starting position; 142 private boolean mUsingCustomStart; 143 144 private Animation.AnimationListener mRefreshListener = new Animation.AnimationListener() { 145 @Override 146 public void onAnimationStart(Animation animation) { 147 } 148 149 @Override 150 public void onAnimationRepeat(Animation animation) { 151 } 152 153 @Override 154 public void onAnimationEnd(Animation animation) { 155 if (mRefreshing) { 156 // Make sure the progress view is fully visible 157 mProgress.setAlpha(MAX_ALPHA); 158 mProgress.start(); 159 if (mNotify) { 160 if (mListener != null) { 161 mListener.onRefresh(); 162 } 163 } 164 } else { 165 mProgress.stop(); 166 mCircleView.setVisibility(View.GONE); 167 // Return the circle to its start position 168 if (mScale) { 169 setAnimationProgress(0 /* animation complete and view is hidden */); 170 } else { 171 setAnimationProgress(1 /* animation complete and view is showing */); 172 setTargetOffsetTopAndBottom(-mCircleHeight - mCurrentTargetOffsetTop, 173 true /* requires update */); 174 mCircleView.setVisibility(View.VISIBLE); 175 } 176 } 177 mCurrentTargetOffsetTop = mCircleView.getTop(); 178 } 179 }; 180 181 private void setColorViewAlpha(int targetAlpha) { 182 mCircleView.getBackground().setAlpha(targetAlpha); 183 mProgress.setAlpha(targetAlpha); 184 } 185 186 /** 187 * The refresh indicator starting and resting position is always positioned 188 * near the top of the refreshing content. This position is a consistent 189 * location, but can be adjusted in either direction based on whether or not 190 * there is a toolbar or actionbar present. 191 * 192 * @param scale Set to true if there is no view at a higher z-order than 193 * where the progress spinner is set to appear. 194 * @param start The offset in pixels from the top of this view at which the 195 * progress spinner should appear. 196 * @param end The offset in pixels from the top of this view at which the 197 * progress spinner should come to rest after a successful swipe 198 * gesture. 199 */ 200 public void setProgressViewOffset(boolean scale, int start, int end) { 201 mScale = scale; 202 mCircleView.setVisibility(View.GONE); 203 mOriginalOffsetTop = mCurrentTargetOffsetTop = start; 204 mSpinnerFinalOffset = end; 205 mUsingCustomStart = true; 206 mCircleView.invalidate(); 207 } 208 209 /** 210 * The refresh indicator resting position is always positioned near the top 211 * of the refreshing content. This position is a consistent location, but 212 * can be adjusted in either direction based on whether or not there is a 213 * toolbar or actionbar present. 214 * 215 * @param scale Set to true if there is no view at a higher z-order than 216 * where the progress spinner is set to appear. 217 * @param end The offset in pixels from the top of this view at which the 218 * progress spinner should come to rest after a successful swipe 219 * gesture. 220 */ 221 public void setProgressViewEndTarget(boolean scale, int end) { 222 mSpinnerFinalOffset = end; 223 mScale = scale; 224 mCircleView.invalidate(); 225 } 226 227 /** 228 * One of DEFAULT, or LARGE. 229 */ 230 public void setSize(int size) { 231 if (size != MaterialProgressDrawable.LARGE && size != MaterialProgressDrawable.DEFAULT) { 232 return; 233 } 234 final DisplayMetrics metrics = getResources().getDisplayMetrics(); 235 if (size == MaterialProgressDrawable.LARGE) { 236 mCircleHeight = mCircleWidth = (int) (CIRCLE_DIAMETER_LARGE * metrics.density); 237 } else { 238 mCircleHeight = mCircleWidth = (int) (CIRCLE_DIAMETER * metrics.density); 239 } 240 // force the bounds of the progress circle inside the circle view to 241 // update by setting it to null before updating its size and then 242 // re-setting it 243 mCircleView.setImageDrawable(null); 244 mProgress.updateSizes(size); 245 mCircleView.setImageDrawable(mProgress); 246 } 247 248 /** 249 * Simple constructor to use when creating a SwipeRefreshLayout from code. 250 * 251 * @param context 252 */ 253 public SwipeRefreshLayout(Context context) { 254 this(context, null); 255 } 256 257 /** 258 * Constructor that is called when inflating SwipeRefreshLayout from XML. 259 * 260 * @param context 261 * @param attrs 262 */ 263 public SwipeRefreshLayout(Context context, AttributeSet attrs) { 264 super(context, attrs); 265 266 mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); 267 268 mMediumAnimationDuration = getResources().getInteger( 269 android.R.integer.config_mediumAnimTime); 270 271 setWillNotDraw(false); 272 mDecelerateInterpolator = new DecelerateInterpolator(DECELERATE_INTERPOLATION_FACTOR); 273 274 final TypedArray a = context.obtainStyledAttributes(attrs, LAYOUT_ATTRS); 275 setEnabled(a.getBoolean(0, true)); 276 a.recycle(); 277 278 final DisplayMetrics metrics = getResources().getDisplayMetrics(); 279 mCircleWidth = (int) (CIRCLE_DIAMETER * metrics.density); 280 mCircleHeight = (int) (CIRCLE_DIAMETER * metrics.density); 281 282 createProgressView(); 283 ViewCompat.setChildrenDrawingOrderEnabled(this, true); 284 // the absolute offset has to take into account that the circle starts at an offset 285 mSpinnerFinalOffset = DEFAULT_CIRCLE_TARGET * metrics.density; 286 mTotalDragDistance = mSpinnerFinalOffset; 287 } 288 289 protected int getChildDrawingOrder (int childCount, int i) { 290 if (getChildAt(i).equals(mCircleView)) { 291 return childCount - 1; 292 } 293 return i; 294 } 295 296 private void createProgressView() { 297 mCircleView = new CircleImageView(getContext(), CIRCLE_BG_LIGHT, CIRCLE_DIAMETER/2); 298 mProgress = new MaterialProgressDrawable(getContext(), this); 299 mProgress.setBackgroundColor(CIRCLE_BG_LIGHT); 300 mCircleView.setImageDrawable(mProgress); 301 addView(mCircleView); 302 } 303 304 /** 305 * Set the listener to be notified when a refresh is triggered via the swipe 306 * gesture. 307 */ 308 public void setOnRefreshListener(OnRefreshListener listener) { 309 mListener = listener; 310 } 311 312 /** 313 * Pre API 11, alpha is used to make the progress circle appear instead of scale. 314 */ 315 private boolean isAlphaUsedForScale() { 316 return android.os.Build.VERSION.SDK_INT < 11; 317 } 318 319 /** 320 * Notify the widget that refresh state has changed. Do not call this when 321 * refresh is triggered by a swipe gesture. 322 * 323 * @param refreshing Whether or not the view should show refresh progress. 324 */ 325 public void setRefreshing(boolean refreshing) { 326 if (refreshing && mRefreshing != refreshing) { 327 // scale and show 328 mRefreshing = refreshing; 329 setTargetOffsetTopAndBottom( 330 (int) ((mSpinnerFinalOffset + mOriginalOffsetTop) - mCurrentTargetOffsetTop), 331 true /* requires update */); 332 mNotify = false; 333 startScaleUpAnimation(mRefreshListener); 334 } else { 335 setRefreshing(refreshing, false /* notify */); 336 } 337 } 338 339 private void startScaleUpAnimation(AnimationListener listener) { 340 mCircleView.setVisibility(View.VISIBLE); 341 if (android.os.Build.VERSION.SDK_INT >= 11) { 342 // Pre API 11, alpha is used in place of scale up to show the 343 // progress circle appearing. 344 // Don't adjust the alpha during appearance otherwise. 345 mProgress.setAlpha(MAX_ALPHA); 346 } 347 mScaleAnimation = new Animation() { 348 @Override 349 public void applyTransformation(float interpolatedTime, Transformation t) { 350 setAnimationProgress(interpolatedTime); 351 } 352 }; 353 mScaleAnimation.setDuration(mMediumAnimationDuration); 354 if (listener != null) { 355 mCircleView.setAnimationListener(listener); 356 } 357 mCircleView.clearAnimation(); 358 mCircleView.startAnimation(mScaleAnimation); 359 } 360 361 /** 362 * Pre API 11, this does an alpha animation. 363 * @param progress 364 */ 365 private void setAnimationProgress(float progress) { 366 if (isAlphaUsedForScale()) { 367 setColorViewAlpha((int) (progress * MAX_ALPHA)); 368 } else { 369 ViewCompat.setScaleX(mCircleView, progress); 370 ViewCompat.setScaleY(mCircleView, progress); 371 } 372 } 373 374 private void setRefreshing(boolean refreshing, final boolean notify) { 375 if (mRefreshing != refreshing) { 376 mNotify = notify; 377 ensureTarget(); 378 mRefreshing = refreshing; 379 if (mRefreshing) { 380 animateOffsetToCorrectPosition(mCurrentTargetOffsetTop, mRefreshListener); 381 } else { 382 startScaleDownAnimation(mRefreshListener); 383 } 384 } 385 } 386 387 private void startScaleDownAnimation(Animation.AnimationListener listener) { 388 mScaleDownAnimation = new Animation() { 389 @Override 390 public void applyTransformation(float interpolatedTime, Transformation t) { 391 setAnimationProgress(1 - interpolatedTime); 392 } 393 }; 394 mScaleDownAnimation.setDuration(SCALE_DOWN_DURATION); 395 if (listener != null) { 396 mCircleView.setAnimationListener(listener); 397 } 398 mCircleView.clearAnimation(); 399 mCircleView.startAnimation(mScaleDownAnimation); 400 } 401 402 private void startProgressAlphaStartAnimation() { 403 mAlphaStartAnimation = startAlphaAnimation(mProgress.getAlpha(), STARTING_PROGRESS_ALPHA); 404 } 405 406 private void startProgressAlphaMaxAnimation() { 407 mAlphaMaxAnimation = startAlphaAnimation(mProgress.getAlpha(), MAX_ALPHA); 408 } 409 410 private Animation startAlphaAnimation(final int startingAlpha, final int endingAlpha) { 411 // Pre API 11, alpha is used in place of scale. Don't also use it to 412 // show the trigger point. 413 if (mScale && isAlphaUsedForScale()) { 414 return null; 415 } 416 Animation alpha = new Animation() { 417 @Override 418 public void applyTransformation(float interpolatedTime, Transformation t) { 419 mProgress 420 .setAlpha((int) (startingAlpha+ ((endingAlpha - startingAlpha) 421 * interpolatedTime))); 422 } 423 }; 424 alpha.setDuration(ALPHA_ANIMATION_DURATION); 425 // Clear out the previous animation listeners. 426 mCircleView.setAnimationListener(null); 427 mCircleView.clearAnimation(); 428 mCircleView.startAnimation(alpha); 429 return alpha; 430 } 431 432 /** 433 * Set the background color of the progress spinner disc. 434 * 435 * @param colorRes Resource id of the color. 436 */ 437 public void setProgressBackgroundColor(int colorRes) { 438 mCircleView.setBackgroundColor(colorRes); 439 mProgress.setBackgroundColor(getResources().getColor(colorRes)); 440 } 441 442 /** 443 * @deprecated Use {@link #setColorSchemeResources(int...)} 444 */ 445 @Deprecated 446 public void setColorScheme(int... colors) { 447 setColorSchemeResources(colors); 448 } 449 450 /** 451 * Set the color resources used in the progress animation from color resources. 452 * The first color will also be the color of the bar that grows in response 453 * to a user swipe gesture. 454 * 455 * @param colorResIds 456 */ 457 public void setColorSchemeResources(int... colorResIds) { 458 final Resources res = getResources(); 459 int[] colorRes = new int[colorResIds.length]; 460 for (int i = 0; i < colorResIds.length; i++) { 461 colorRes[i] = res.getColor(colorResIds[i]); 462 } 463 setColorSchemeColors(colorRes); 464 } 465 466 /** 467 * Set the colors used in the progress animation. The first 468 * color will also be the color of the bar that grows in response to a user 469 * swipe gesture. 470 * 471 * @param colors 472 */ 473 public void setColorSchemeColors(int... colors) { 474 ensureTarget(); 475 mProgress.setColorSchemeColors(colors); 476 } 477 478 /** 479 * @return Whether the SwipeRefreshWidget is actively showing refresh 480 * progress. 481 */ 482 public boolean isRefreshing() { 483 return mRefreshing; 484 } 485 486 private void ensureTarget() { 487 // Don't bother getting the parent height if the parent hasn't been laid 488 // out yet. 489 if (mTarget == null) { 490 for (int i = 0; i < getChildCount(); i++) { 491 View child = getChildAt(i); 492 if (!child.equals(mCircleView)) { 493 mTarget = child; 494 break; 495 } 496 } 497 } 498 } 499 500 /** 501 * Set the distance to trigger a sync in dips 502 * 503 * @param distance 504 */ 505 public void setDistanceToTriggerSync(int distance) { 506 mTotalDragDistance = distance; 507 } 508 509 @Override 510 protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 511 final int width = getMeasuredWidth(); 512 final int height = getMeasuredHeight(); 513 if (getChildCount() == 0) { 514 return; 515 } 516 if (mTarget == null) { 517 ensureTarget(); 518 } 519 if (mTarget == null) { 520 return; 521 } 522 final View child = mTarget; 523 final int childLeft = getPaddingLeft(); 524 final int childTop = getPaddingTop(); 525 final int childWidth = width - getPaddingLeft() - getPaddingRight(); 526 final int childHeight = height - getPaddingTop() - getPaddingBottom(); 527 child.layout(childLeft, childTop, childLeft + childWidth, childTop + childHeight); 528 int circleWidth = mCircleView.getMeasuredWidth(); 529 int circleHeight = mCircleView.getMeasuredHeight(); 530 mCircleView.layout((width / 2 - circleWidth / 2), mCurrentTargetOffsetTop, 531 (width / 2 + circleWidth / 2), mCurrentTargetOffsetTop + circleHeight); 532 } 533 534 @Override 535 public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 536 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 537 if (mTarget == null) { 538 ensureTarget(); 539 } 540 if (mTarget == null) { 541 return; 542 } 543 mTarget.measure(MeasureSpec.makeMeasureSpec( 544 getMeasuredWidth() - getPaddingLeft() - getPaddingRight(), 545 MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec( 546 getMeasuredHeight() - getPaddingTop() - getPaddingBottom(), MeasureSpec.EXACTLY)); 547 mCircleView.measure(MeasureSpec.makeMeasureSpec(mCircleWidth, MeasureSpec.EXACTLY), 548 MeasureSpec.makeMeasureSpec(mCircleHeight, MeasureSpec.EXACTLY)); 549 if (!mUsingCustomStart && !mOriginalOffsetCalculated) { 550 mOriginalOffsetCalculated = true; 551 mCurrentTargetOffsetTop = mOriginalOffsetTop = -mCircleView.getMeasuredHeight(); 552 } 553 } 554 555 /** 556 * @return Whether it is possible for the child view of this layout to 557 * scroll up. Override this if the child view is a custom view. 558 */ 559 public boolean canChildScrollUp() { 560 if (android.os.Build.VERSION.SDK_INT < 14) { 561 if (mTarget instanceof AbsListView) { 562 final AbsListView absListView = (AbsListView) mTarget; 563 return absListView.getChildCount() > 0 564 && (absListView.getFirstVisiblePosition() > 0 || absListView.getChildAt(0) 565 .getTop() < absListView.getPaddingTop()); 566 } else { 567 return mTarget.getScrollY() > 0; 568 } 569 } else { 570 return ViewCompat.canScrollVertically(mTarget, -1); 571 } 572 } 573 574 @Override 575 public boolean onInterceptTouchEvent(MotionEvent ev) { 576 ensureTarget(); 577 578 final int action = MotionEventCompat.getActionMasked(ev); 579 580 if (mReturningToStart && action == MotionEvent.ACTION_DOWN) { 581 mReturningToStart = false; 582 } 583 584 if (!isEnabled() || mReturningToStart || canChildScrollUp() || mRefreshing) { 585 // Fail fast if we're not in a state where a swipe is possible 586 return false; 587 } 588 589 switch (action) { 590 case MotionEvent.ACTION_DOWN: 591 setTargetOffsetTopAndBottom(mOriginalOffsetTop - mCircleView.getTop(), true); 592 mActivePointerId = MotionEventCompat.getPointerId(ev, 0); 593 mIsBeingDragged = false; 594 final float initialMotionY = getMotionEventY(ev, mActivePointerId); 595 if (initialMotionY == -1) { 596 return false; 597 } 598 mInitialMotionY = initialMotionY; 599 600 case MotionEvent.ACTION_MOVE: 601 if (mActivePointerId == INVALID_POINTER) { 602 Log.e(LOG_TAG, "Got ACTION_MOVE event but don't have an active pointer id."); 603 return false; 604 } 605 606 final float y = getMotionEventY(ev, mActivePointerId); 607 if (y == -1) { 608 return false; 609 } 610 final float yDiff = y - mInitialMotionY; 611 if (yDiff > mTouchSlop && !mIsBeingDragged) { 612 mIsBeingDragged = true; 613 mProgress.setAlpha(STARTING_PROGRESS_ALPHA); 614 } 615 break; 616 617 case MotionEventCompat.ACTION_POINTER_UP: 618 onSecondaryPointerUp(ev); 619 break; 620 621 case MotionEvent.ACTION_UP: 622 case MotionEvent.ACTION_CANCEL: 623 mIsBeingDragged = false; 624 mActivePointerId = INVALID_POINTER; 625 break; 626 } 627 628 return mIsBeingDragged; 629 } 630 631 private float getMotionEventY(MotionEvent ev, int activePointerId) { 632 final int index = MotionEventCompat.findPointerIndex(ev, activePointerId); 633 if (index < 0) { 634 return -1; 635 } 636 return MotionEventCompat.getY(ev, index); 637 } 638 639 @Override 640 public void requestDisallowInterceptTouchEvent(boolean b) { 641 // Nope. 642 } 643 644 private boolean isAnimationRunning(Animation animation) { 645 return animation != null && animation.hasStarted() && !animation.hasEnded(); 646 } 647 648 @Override 649 public boolean onTouchEvent(MotionEvent ev) { 650 final int action = MotionEventCompat.getActionMasked(ev); 651 652 if (mReturningToStart && action == MotionEvent.ACTION_DOWN) { 653 mReturningToStart = false; 654 } 655 656 if (!isEnabled() || mReturningToStart || canChildScrollUp()) { 657 // Fail fast if we're not in a state where a swipe is possible 658 return false; 659 } 660 661 switch (action) { 662 case MotionEvent.ACTION_DOWN: 663 mActivePointerId = MotionEventCompat.getPointerId(ev, 0); 664 mIsBeingDragged = false; 665 break; 666 667 case MotionEvent.ACTION_MOVE: { 668 final int pointerIndex = MotionEventCompat.findPointerIndex(ev, mActivePointerId); 669 if (pointerIndex < 0) { 670 Log.e(LOG_TAG, "Got ACTION_MOVE event but have an invalid active pointer id."); 671 return false; 672 } 673 674 final float y = MotionEventCompat.getY(ev, pointerIndex); 675 final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE; 676 if (mIsBeingDragged) { 677 mProgress.showArrow(true); 678 float originalDragPercent = overscrollTop / mTotalDragDistance; 679 if (originalDragPercent < 0) { 680 return false; 681 } 682 float dragPercent = Math.min(1f, Math.abs(originalDragPercent)); 683 float adjustedPercent = (float) Math.max(dragPercent - .4, 0) * 5 / 3; 684 float extraOS = Math.abs(overscrollTop) - mTotalDragDistance; 685 float slingshotDist = mSpinnerFinalOffset; 686 float tensionSlingshotPercent = Math.max(0, 687 Math.min(extraOS, slingshotDist * 2) / slingshotDist); 688 float tensionPercent = (float) ((tensionSlingshotPercent / 4) - Math.pow( 689 (tensionSlingshotPercent / 4), 2)) * 2f; 690 float extraMove = (slingshotDist) * tensionPercent * 2; 691 692 int targetY = mOriginalOffsetTop 693 + (int) ((slingshotDist * dragPercent) + extraMove); 694 // where 1.0f is a full circle 695 if (mCircleView.getVisibility() != View.VISIBLE) { 696 mCircleView.setVisibility(View.VISIBLE); 697 } 698 if (overscrollTop < mTotalDragDistance) { 699 if (mScale) { 700 setAnimationProgress(overscrollTop / mTotalDragDistance); 701 } 702 if (mProgress.getAlpha() > STARTING_PROGRESS_ALPHA 703 && !isAnimationRunning(mAlphaStartAnimation)) { 704 // Animate the alpha 705 startProgressAlphaStartAnimation(); 706 } 707 float strokeStart = (float) (adjustedPercent * .8f); 708 mProgress.setStartEndTrim(0f, Math.min(MAX_PROGRESS_ANGLE, strokeStart)); 709 mProgress.setArrowScale(Math.min(1f, adjustedPercent)); 710 } else { 711 if (mProgress.getAlpha() < MAX_ALPHA 712 && !isAnimationRunning(mAlphaMaxAnimation)) { 713 // Animate the alpha 714 startProgressAlphaMaxAnimation(); 715 } 716 } 717 float rotation = (-0.25f + .4f * adjustedPercent + tensionPercent * 2) * .5f; 718 mProgress.setProgressRotation(rotation); 719 setTargetOffsetTopAndBottom(targetY - mCurrentTargetOffsetTop, 720 true /* requires update */); 721 } 722 break; 723 } 724 case MotionEventCompat.ACTION_POINTER_DOWN: { 725 final int index = MotionEventCompat.getActionIndex(ev); 726 mActivePointerId = MotionEventCompat.getPointerId(ev, index); 727 break; 728 } 729 730 case MotionEventCompat.ACTION_POINTER_UP: 731 onSecondaryPointerUp(ev); 732 break; 733 734 case MotionEvent.ACTION_UP: 735 case MotionEvent.ACTION_CANCEL: { 736 final int pointerIndex = MotionEventCompat.findPointerIndex(ev, mActivePointerId); 737 final float y = MotionEventCompat.getY(ev, pointerIndex); 738 final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE; 739 mIsBeingDragged = false; 740 if (overscrollTop > mTotalDragDistance) { 741 setRefreshing(true, true /* notify */); 742 } else { 743 // cancel refresh 744 mRefreshing = false; 745 mProgress.setStartEndTrim(0f, 0f); 746 animateOffsetToStartPosition(mCurrentTargetOffsetTop, null); 747 mProgress.showArrow(false); 748 } 749 mActivePointerId = INVALID_POINTER; 750 return false; 751 } 752 } 753 754 return true; 755 } 756 757 private void animateOffsetToCorrectPosition(int from, AnimationListener listener) { 758 mFrom = from; 759 mAnimateToCorrectPosition.reset(); 760 mAnimateToCorrectPosition.setDuration(ANIMATE_TO_TRIGGER_DURATION); 761 mAnimateToCorrectPosition.setInterpolator(mDecelerateInterpolator); 762 if (listener != null) { 763 mCircleView.setAnimationListener(listener); 764 } 765 mCircleView.clearAnimation(); 766 mCircleView.startAnimation(mAnimateToCorrectPosition); 767 } 768 769 private void animateOffsetToStartPosition(int from, AnimationListener listener) { 770 mFrom = from; 771 mAnimateToStartPosition.reset(); 772 mAnimateToStartPosition.setDuration(ANIMATE_TO_START_DURATION); 773 mAnimateToStartPosition.setInterpolator(mDecelerateInterpolator); 774 if (listener != null) { 775 mCircleView.setAnimationListener(listener); 776 } 777 mCircleView.clearAnimation(); 778 mCircleView.startAnimation(mAnimateToStartPosition); 779 } 780 781 private final Animation mAnimateToCorrectPosition = new Animation() { 782 @Override 783 public void applyTransformation(float interpolatedTime, Transformation t) { 784 int targetTop = 0; 785 int endTarget = (int) (mSpinnerFinalOffset - Math.abs(mOriginalOffsetTop)); 786 targetTop = (mFrom + (int) ((endTarget - mFrom) * interpolatedTime)); 787 int offset = targetTop - mCircleView.getTop(); 788 setTargetOffsetTopAndBottom(offset, false /* requires update */); 789 } 790 }; 791 792 private final Animation mAnimateToStartPosition = new Animation() { 793 @Override 794 public void applyTransformation(float interpolatedTime, Transformation t) { 795 int targetTop = 0; 796 targetTop = (mFrom + (int) ((mOriginalOffsetTop - mFrom) * interpolatedTime)); 797 int offset = targetTop - mCircleView.getTop(); 798 setTargetOffsetTopAndBottom(offset, false /* requires update */); 799 } 800 }; 801 802 private void setTargetOffsetTopAndBottom(int offset, boolean requiresUpdate) { 803 mCircleView.bringToFront(); 804 mCircleView.offsetTopAndBottom(offset); 805 mCurrentTargetOffsetTop = mCircleView.getTop(); 806 if (requiresUpdate && android.os.Build.VERSION.SDK_INT < 11) { 807 invalidate(); 808 } 809 } 810 811 private void onSecondaryPointerUp(MotionEvent ev) { 812 final int pointerIndex = MotionEventCompat.getActionIndex(ev); 813 final int pointerId = MotionEventCompat.getPointerId(ev, pointerIndex); 814 if (pointerId == mActivePointerId) { 815 // This was our active pointer going up. Choose a new 816 // active pointer and adjust accordingly. 817 final int newPointerIndex = pointerIndex == 0 ? 1 : 0; 818 mActivePointerId = MotionEventCompat.getPointerId(ev, newPointerIndex); 819 } 820 } 821 822 /** 823 * Classes that wish to be notified when the swipe gesture correctly 824 * triggers a refresh should implement this interface. 825 */ 826 public interface OnRefreshListener { 827 public void onRefresh(); 828 } 829} 830