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