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