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