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