1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/mobile/android/base/widget/GeckoSwipeRefreshLayout.java Wed Dec 31 06:09:35 2014 +0100 1.3 @@ -0,0 +1,795 @@ 1.4 +/* 1.5 + * Copyright (C) 2013 The Android Open Source Project 1.6 + * 1.7 + * Licensed under the Apache License, Version 2.0 (the "License"); 1.8 + * you may not use this file except in compliance with the License. 1.9 + * You may obtain a copy of the License at 1.10 + * 1.11 + * http://www.apache.org/licenses/LICENSE-2.0 1.12 + * 1.13 + * Unless required by applicable law or agreed to in writing, software 1.14 + * distributed under the License is distributed on an "AS IS" BASIS, 1.15 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 1.16 + * See the License for the specific language governing permissions and 1.17 + * limitations under the License. 1.18 + */ 1.19 + 1.20 +package org.mozilla.gecko.widget; 1.21 + 1.22 +import android.content.Context; 1.23 +import android.content.res.Resources; 1.24 +import android.content.res.TypedArray; 1.25 +import android.graphics.Canvas; 1.26 +import android.graphics.Paint; 1.27 +import android.graphics.Rect; 1.28 +import android.graphics.RectF; 1.29 +import android.support.v4.view.ViewCompat; 1.30 +import android.util.AttributeSet; 1.31 +import android.util.DisplayMetrics; 1.32 +import android.view.MotionEvent; 1.33 +import android.view.View; 1.34 +import android.view.ViewConfiguration; 1.35 +import android.view.ViewGroup; 1.36 +import android.view.animation.AccelerateInterpolator; 1.37 +import android.view.animation.Animation; 1.38 +import android.view.animation.Animation.AnimationListener; 1.39 +import android.view.animation.AnimationUtils; 1.40 +import android.view.animation.DecelerateInterpolator; 1.41 +import android.view.animation.Interpolator; 1.42 +import android.view.animation.Transformation; 1.43 +import android.widget.AbsListView; 1.44 + 1.45 +/** 1.46 + * GeckoSwipeRefreshLayout is mostly lifted from Android's support library (v4) with these 1.47 + * modfications: 1.48 + * - Removes elastic "rubber banding" effect when overscrolling the child view. 1.49 + * - Changes the height of the progress bar to match the height of HomePager's page indicator. 1.50 + * - Uses a rectangle rather than a circle for the SwipeProgressBar indicator. 1.51 + * 1.52 + * This class also embeds package-access dependent classes SwipeProgressBar and 1.53 + * BakedBezierInterpolator. 1.54 + * 1.55 + * Original source: https://android.googlesource.com/platform/frameworks/support/+/ 1.56 + * android-support-lib-19.1.0/v4/java/android/support/v4/widget/SwipeRefreshLayout.java 1.57 + */ 1.58 +public class GeckoSwipeRefreshLayout extends ViewGroup { 1.59 + private static final long RETURN_TO_ORIGINAL_POSITION_TIMEOUT = 300; 1.60 + private static final float ACCELERATE_INTERPOLATION_FACTOR = 1.5f; 1.61 + private static final float DECELERATE_INTERPOLATION_FACTOR = 2f; 1.62 + // Reduce the height (from 4 to 3) of the progress bar to match HomePager's page indicator. 1.63 + private static final float PROGRESS_BAR_HEIGHT = 3; 1.64 + private static final float MAX_SWIPE_DISTANCE_FACTOR = .6f; 1.65 + private static final int REFRESH_TRIGGER_DISTANCE = 120; 1.66 + 1.67 + private SwipeProgressBar mProgressBar; //the thing that shows progress is going 1.68 + private View mTarget; //the content that gets pulled down 1.69 + private int mOriginalOffsetTop; 1.70 + private OnRefreshListener mListener; 1.71 + private MotionEvent mDownEvent; 1.72 + private int mFrom; 1.73 + private boolean mRefreshing = false; 1.74 + private int mTouchSlop; 1.75 + private float mDistanceToTriggerSync = -1; 1.76 + private float mPrevY; 1.77 + private int mMediumAnimationDuration; 1.78 + private float mFromPercentage = 0; 1.79 + private float mCurrPercentage = 0; 1.80 + private int mProgressBarHeight; 1.81 + private int mCurrentTargetOffsetTop; 1.82 + // Target is returning to its start offset because it was cancelled or a 1.83 + // refresh was triggered. 1.84 + private boolean mReturningToStart; 1.85 + private final DecelerateInterpolator mDecelerateInterpolator; 1.86 + private final AccelerateInterpolator mAccelerateInterpolator; 1.87 + private static final int[] LAYOUT_ATTRS = new int[] { 1.88 + android.R.attr.enabled 1.89 + }; 1.90 + 1.91 + private final Animation mAnimateToStartPosition = new Animation() { 1.92 + @Override 1.93 + public void applyTransformation(float interpolatedTime, Transformation t) { 1.94 + int targetTop = 0; 1.95 + if (mFrom != mOriginalOffsetTop) { 1.96 + targetTop = (mFrom + (int)((mOriginalOffsetTop - mFrom) * interpolatedTime)); 1.97 + } 1.98 + int offset = targetTop - mTarget.getTop(); 1.99 + final int currentTop = mTarget.getTop(); 1.100 + if (offset + currentTop < 0) { 1.101 + offset = 0 - currentTop; 1.102 + } 1.103 + setTargetOffsetTopAndBottom(offset); 1.104 + } 1.105 + }; 1.106 + 1.107 + private Animation mShrinkTrigger = new Animation() { 1.108 + @Override 1.109 + public void applyTransformation(float interpolatedTime, Transformation t) { 1.110 + float percent = mFromPercentage + ((0 - mFromPercentage) * interpolatedTime); 1.111 + mProgressBar.setTriggerPercentage(percent); 1.112 + } 1.113 + }; 1.114 + 1.115 + private final AnimationListener mReturnToStartPositionListener = new BaseAnimationListener() { 1.116 + @Override 1.117 + public void onAnimationEnd(Animation animation) { 1.118 + // Once the target content has returned to its start position, reset 1.119 + // the target offset to 0 1.120 + mCurrentTargetOffsetTop = 0; 1.121 + } 1.122 + }; 1.123 + 1.124 + private final AnimationListener mShrinkAnimationListener = new BaseAnimationListener() { 1.125 + @Override 1.126 + public void onAnimationEnd(Animation animation) { 1.127 + mCurrPercentage = 0; 1.128 + } 1.129 + }; 1.130 + 1.131 + private final Runnable mReturnToStartPosition = new Runnable() { 1.132 + 1.133 + @Override 1.134 + public void run() { 1.135 + mReturningToStart = true; 1.136 + animateOffsetToStartPosition(mCurrentTargetOffsetTop + getPaddingTop(), 1.137 + mReturnToStartPositionListener); 1.138 + } 1.139 + 1.140 + }; 1.141 + 1.142 + // Cancel the refresh gesture and animate everything back to its original state. 1.143 + private final Runnable mCancel = new Runnable() { 1.144 + 1.145 + @Override 1.146 + public void run() { 1.147 + mReturningToStart = true; 1.148 + // Timeout fired since the user last moved their finger; animate the 1.149 + // trigger to 0 and put the target back at its original position 1.150 + if (mProgressBar != null) { 1.151 + mFromPercentage = mCurrPercentage; 1.152 + mShrinkTrigger.setDuration(mMediumAnimationDuration); 1.153 + mShrinkTrigger.setAnimationListener(mShrinkAnimationListener); 1.154 + mShrinkTrigger.reset(); 1.155 + mShrinkTrigger.setInterpolator(mDecelerateInterpolator); 1.156 + startAnimation(mShrinkTrigger); 1.157 + } 1.158 + animateOffsetToStartPosition(mCurrentTargetOffsetTop + getPaddingTop(), 1.159 + mReturnToStartPositionListener); 1.160 + } 1.161 + 1.162 + }; 1.163 + 1.164 + /** 1.165 + * Simple constructor to use when creating a GeckoSwipeRefreshLayout from code. 1.166 + * @param context 1.167 + */ 1.168 + public GeckoSwipeRefreshLayout(Context context) { 1.169 + this(context, null); 1.170 + } 1.171 + 1.172 + /** 1.173 + * Constructor that is called when inflating GeckoSwipeRefreshLayout from XML. 1.174 + * @param context 1.175 + * @param attrs 1.176 + */ 1.177 + public GeckoSwipeRefreshLayout(Context context, AttributeSet attrs) { 1.178 + super(context, attrs); 1.179 + 1.180 + mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); 1.181 + 1.182 + mMediumAnimationDuration = getResources().getInteger( 1.183 + android.R.integer.config_mediumAnimTime); 1.184 + 1.185 + setWillNotDraw(false); 1.186 + mProgressBar = new SwipeProgressBar(this); 1.187 + final DisplayMetrics metrics = getResources().getDisplayMetrics(); 1.188 + mProgressBarHeight = (int) (metrics.density * PROGRESS_BAR_HEIGHT); 1.189 + mDecelerateInterpolator = new DecelerateInterpolator(DECELERATE_INTERPOLATION_FACTOR); 1.190 + mAccelerateInterpolator = new AccelerateInterpolator(ACCELERATE_INTERPOLATION_FACTOR); 1.191 + 1.192 + final TypedArray a = context.obtainStyledAttributes(attrs, LAYOUT_ATTRS); 1.193 + setEnabled(a.getBoolean(0, true)); 1.194 + a.recycle(); 1.195 + } 1.196 + 1.197 + @Override 1.198 + public void onAttachedToWindow() { 1.199 + super.onAttachedToWindow(); 1.200 + removeCallbacks(mCancel); 1.201 + removeCallbacks(mReturnToStartPosition); 1.202 + } 1.203 + 1.204 + @Override 1.205 + public void onDetachedFromWindow() { 1.206 + super.onDetachedFromWindow(); 1.207 + removeCallbacks(mReturnToStartPosition); 1.208 + removeCallbacks(mCancel); 1.209 + } 1.210 + 1.211 + private void animateOffsetToStartPosition(int from, AnimationListener listener) { 1.212 + mFrom = from; 1.213 + mAnimateToStartPosition.reset(); 1.214 + mAnimateToStartPosition.setDuration(mMediumAnimationDuration); 1.215 + mAnimateToStartPosition.setAnimationListener(listener); 1.216 + mAnimateToStartPosition.setInterpolator(mDecelerateInterpolator); 1.217 + mTarget.startAnimation(mAnimateToStartPosition); 1.218 + } 1.219 + 1.220 + /** 1.221 + * Set the listener to be notified when a refresh is triggered via the swipe 1.222 + * gesture. 1.223 + */ 1.224 + public void setOnRefreshListener(OnRefreshListener listener) { 1.225 + mListener = listener; 1.226 + } 1.227 + 1.228 + private void setTriggerPercentage(float percent) { 1.229 + if (percent == 0f) { 1.230 + // No-op. A null trigger means it's uninitialized, and setting it to zero-percent 1.231 + // means we're trying to reset state, so there's nothing to reset in this case. 1.232 + mCurrPercentage = 0; 1.233 + return; 1.234 + } 1.235 + mCurrPercentage = percent; 1.236 + mProgressBar.setTriggerPercentage(percent); 1.237 + } 1.238 + 1.239 + /** 1.240 + * Notify the widget that refresh state has changed. Do not call this when 1.241 + * refresh is triggered by a swipe gesture. 1.242 + * 1.243 + * @param refreshing Whether or not the view should show refresh progress. 1.244 + */ 1.245 + public void setRefreshing(boolean refreshing) { 1.246 + if (mRefreshing != refreshing) { 1.247 + ensureTarget(); 1.248 + mCurrPercentage = 0; 1.249 + mRefreshing = refreshing; 1.250 + if (mRefreshing) { 1.251 + mProgressBar.start(); 1.252 + } else { 1.253 + mProgressBar.stop(); 1.254 + } 1.255 + } 1.256 + } 1.257 + 1.258 + /** 1.259 + * Set the four colors used in the progress animation. The first color will 1.260 + * also be the color of the bar that grows in response to a user swipe 1.261 + * gesture. 1.262 + * 1.263 + * @param colorRes1 Color resource. 1.264 + * @param colorRes2 Color resource. 1.265 + * @param colorRes3 Color resource. 1.266 + * @param colorRes4 Color resource. 1.267 + */ 1.268 + public void setColorScheme(int colorRes1, int colorRes2, int colorRes3, int colorRes4) { 1.269 + ensureTarget(); 1.270 + final Resources res = getResources(); 1.271 + final int color1 = res.getColor(colorRes1); 1.272 + final int color2 = res.getColor(colorRes2); 1.273 + final int color3 = res.getColor(colorRes3); 1.274 + final int color4 = res.getColor(colorRes4); 1.275 + mProgressBar.setColorScheme(color1, color2, color3,color4); 1.276 + } 1.277 + 1.278 + /** 1.279 + * @return Whether the SwipeRefreshWidget is actively showing refresh 1.280 + * progress. 1.281 + */ 1.282 + public boolean isRefreshing() { 1.283 + return mRefreshing; 1.284 + } 1.285 + 1.286 + private void ensureTarget() { 1.287 + // Don't bother getting the parent height if the parent hasn't been laid out yet. 1.288 + if (mTarget == null) { 1.289 + if (getChildCount() > 1 && !isInEditMode()) { 1.290 + throw new IllegalStateException( 1.291 + "GeckoSwipeRefreshLayout can host only one direct child"); 1.292 + } 1.293 + mTarget = getChildAt(0); 1.294 + mOriginalOffsetTop = mTarget.getTop() + getPaddingTop(); 1.295 + } 1.296 + if (mDistanceToTriggerSync == -1) { 1.297 + if (getParent() != null && ((View)getParent()).getHeight() > 0) { 1.298 + final DisplayMetrics metrics = getResources().getDisplayMetrics(); 1.299 + mDistanceToTriggerSync = (int) Math.min( 1.300 + ((View) getParent()) .getHeight() * MAX_SWIPE_DISTANCE_FACTOR, 1.301 + REFRESH_TRIGGER_DISTANCE * metrics.density); 1.302 + } 1.303 + } 1.304 + } 1.305 + 1.306 + @Override 1.307 + public void draw(Canvas canvas) { 1.308 + super.draw(canvas); 1.309 + mProgressBar.draw(canvas); 1.310 + } 1.311 + 1.312 + @Override 1.313 + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 1.314 + final int width = getMeasuredWidth(); 1.315 + final int height = getMeasuredHeight(); 1.316 + mProgressBar.setBounds(0, 0, width, mProgressBarHeight); 1.317 + if (getChildCount() == 0) { 1.318 + return; 1.319 + } 1.320 + final View child = getChildAt(0); 1.321 + final int childLeft = getPaddingLeft(); 1.322 + final int childTop = mCurrentTargetOffsetTop + getPaddingTop(); 1.323 + final int childWidth = width - getPaddingLeft() - getPaddingRight(); 1.324 + final int childHeight = height - getPaddingTop() - getPaddingBottom(); 1.325 + child.layout(childLeft, childTop, childLeft + childWidth, childTop + childHeight); 1.326 + } 1.327 + 1.328 + @Override 1.329 + public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 1.330 + super.onMeasure(widthMeasureSpec, heightMeasureSpec); 1.331 + if (getChildCount() > 1 && !isInEditMode()) { 1.332 + throw new IllegalStateException("GeckoSwipeRefreshLayout can host only one direct child"); 1.333 + } 1.334 + if (getChildCount() > 0) { 1.335 + getChildAt(0).measure( 1.336 + MeasureSpec.makeMeasureSpec( 1.337 + getMeasuredWidth() - getPaddingLeft() - getPaddingRight(), 1.338 + MeasureSpec.EXACTLY), 1.339 + MeasureSpec.makeMeasureSpec( 1.340 + getMeasuredHeight() - getPaddingTop() - getPaddingBottom(), 1.341 + MeasureSpec.EXACTLY)); 1.342 + } 1.343 + } 1.344 + 1.345 + /** 1.346 + * @return Whether it is possible for the child view of this layout to 1.347 + * scroll up. Override this if the child view is a custom view. 1.348 + */ 1.349 + public boolean canChildScrollUp() { 1.350 + if (android.os.Build.VERSION.SDK_INT < 14) { 1.351 + if (mTarget instanceof AbsListView) { 1.352 + final AbsListView absListView = (AbsListView) mTarget; 1.353 + return absListView.getChildCount() > 0 1.354 + && (absListView.getFirstVisiblePosition() > 0 || absListView.getChildAt(0) 1.355 + .getTop() < absListView.getPaddingTop()); 1.356 + } else { 1.357 + return mTarget.getScrollY() > 0; 1.358 + } 1.359 + } else { 1.360 + return ViewCompat.canScrollVertically(mTarget, -1); 1.361 + } 1.362 + } 1.363 + 1.364 + @Override 1.365 + public boolean onInterceptTouchEvent(MotionEvent ev) { 1.366 + ensureTarget(); 1.367 + boolean handled = false; 1.368 + if (mReturningToStart && ev.getAction() == MotionEvent.ACTION_DOWN) { 1.369 + mReturningToStart = false; 1.370 + } 1.371 + if (isEnabled() && !mReturningToStart && !canChildScrollUp()) { 1.372 + handled = onTouchEvent(ev); 1.373 + } 1.374 + return !handled ? super.onInterceptTouchEvent(ev) : handled; 1.375 + } 1.376 + 1.377 + @Override 1.378 + public void requestDisallowInterceptTouchEvent(boolean b) { 1.379 + // Nope. 1.380 + } 1.381 + 1.382 + @Override 1.383 + public boolean onTouchEvent(MotionEvent event) { 1.384 + final int action = event.getAction(); 1.385 + boolean handled = false; 1.386 + switch (action) { 1.387 + case MotionEvent.ACTION_DOWN: 1.388 + mCurrPercentage = 0; 1.389 + mDownEvent = MotionEvent.obtain(event); 1.390 + mPrevY = mDownEvent.getY(); 1.391 + break; 1.392 + case MotionEvent.ACTION_MOVE: 1.393 + if (mDownEvent != null && !mReturningToStart) { 1.394 + final float eventY = event.getY(); 1.395 + float yDiff = eventY - mDownEvent.getY(); 1.396 + if (yDiff > mTouchSlop) { 1.397 + // User velocity passed min velocity; trigger a refresh 1.398 + if (yDiff > mDistanceToTriggerSync) { 1.399 + // User movement passed distance; trigger a refresh 1.400 + startRefresh(); 1.401 + handled = true; 1.402 + break; 1.403 + } else { 1.404 + // Just track the user's movement 1.405 + setTriggerPercentage( 1.406 + mAccelerateInterpolator.getInterpolation( 1.407 + yDiff / mDistanceToTriggerSync)); 1.408 + float offsetTop = yDiff; 1.409 + if (mPrevY > eventY) { 1.410 + offsetTop = yDiff - mTouchSlop; 1.411 + } 1.412 + // Removed this call to disable "rubber-band" overscroll effect. 1.413 + // updateContentOffsetTop((int) offsetTop); 1.414 + if (mPrevY > eventY && (mTarget.getTop() < mTouchSlop)) { 1.415 + // If the user puts the view back at the top, we 1.416 + // don't need to. This shouldn't be considered 1.417 + // cancelling the gesture as the user can restart from the top. 1.418 + removeCallbacks(mCancel); 1.419 + } else { 1.420 + updatePositionTimeout(); 1.421 + } 1.422 + mPrevY = event.getY(); 1.423 + handled = true; 1.424 + } 1.425 + } 1.426 + } 1.427 + break; 1.428 + case MotionEvent.ACTION_UP: 1.429 + case MotionEvent.ACTION_CANCEL: 1.430 + if (mDownEvent != null) { 1.431 + mDownEvent.recycle(); 1.432 + mDownEvent = null; 1.433 + } 1.434 + break; 1.435 + } 1.436 + return handled; 1.437 + } 1.438 + 1.439 + private void startRefresh() { 1.440 + removeCallbacks(mCancel); 1.441 + mReturnToStartPosition.run(); 1.442 + setRefreshing(true); 1.443 + mListener.onRefresh(); 1.444 + } 1.445 + 1.446 + private void updateContentOffsetTop(int targetTop) { 1.447 + final int currentTop = mTarget.getTop(); 1.448 + if (targetTop > mDistanceToTriggerSync) { 1.449 + targetTop = (int) mDistanceToTriggerSync; 1.450 + } else if (targetTop < 0) { 1.451 + targetTop = 0; 1.452 + } 1.453 + setTargetOffsetTopAndBottom(targetTop - currentTop); 1.454 + } 1.455 + 1.456 + private void setTargetOffsetTopAndBottom(int offset) { 1.457 + mTarget.offsetTopAndBottom(offset); 1.458 + mCurrentTargetOffsetTop = mTarget.getTop(); 1.459 + } 1.460 + 1.461 + private void updatePositionTimeout() { 1.462 + removeCallbacks(mCancel); 1.463 + postDelayed(mCancel, RETURN_TO_ORIGINAL_POSITION_TIMEOUT); 1.464 + } 1.465 + 1.466 + /** 1.467 + * Classes that wish to be notified when the swipe gesture correctly 1.468 + * triggers a refresh should implement this interface. 1.469 + */ 1.470 + public interface OnRefreshListener { 1.471 + public void onRefresh(); 1.472 + } 1.473 + 1.474 + /** 1.475 + * Simple AnimationListener to avoid having to implement unneeded methods in 1.476 + * AnimationListeners. 1.477 + */ 1.478 + private class BaseAnimationListener implements AnimationListener { 1.479 + @Override 1.480 + public void onAnimationStart(Animation animation) { 1.481 + } 1.482 + 1.483 + @Override 1.484 + public void onAnimationEnd(Animation animation) { 1.485 + } 1.486 + 1.487 + @Override 1.488 + public void onAnimationRepeat(Animation animation) { 1.489 + } 1.490 + } 1.491 + 1.492 + /** 1.493 + * The only modification to this class is the shape of the refresh indicator to be a rectangle 1.494 + * rather than a circle. 1.495 + */ 1.496 + private static final class SwipeProgressBar { 1.497 + // Default progress animation colors are grays. 1.498 + private final static int COLOR1 = 0xB3000000; 1.499 + private final static int COLOR2 = 0x80000000; 1.500 + private final static int COLOR3 = 0x4d000000; 1.501 + private final static int COLOR4 = 0x1a000000; 1.502 + 1.503 + // The duration of the animation cycle. 1.504 + private static final int ANIMATION_DURATION_MS = 2000; 1.505 + 1.506 + // The duration of the animation to clear the bar. 1.507 + private static final int FINISH_ANIMATION_DURATION_MS = 1000; 1.508 + 1.509 + // Interpolator for varying the speed of the animation. 1.510 + private static final Interpolator INTERPOLATOR = BakedBezierInterpolator.getInstance(); 1.511 + 1.512 + private final Paint mPaint = new Paint(); 1.513 + private final RectF mClipRect = new RectF(); 1.514 + private float mTriggerPercentage; 1.515 + private long mStartTime; 1.516 + private long mFinishTime; 1.517 + private boolean mRunning; 1.518 + 1.519 + // Colors used when rendering the animation, 1.520 + private int mColor1; 1.521 + private int mColor2; 1.522 + private int mColor3; 1.523 + private int mColor4; 1.524 + private View mParent; 1.525 + 1.526 + private Rect mBounds = new Rect(); 1.527 + 1.528 + public SwipeProgressBar(View parent) { 1.529 + mParent = parent; 1.530 + mColor1 = COLOR1; 1.531 + mColor2 = COLOR2; 1.532 + mColor3 = COLOR3; 1.533 + mColor4 = COLOR4; 1.534 + } 1.535 + 1.536 + /** 1.537 + * Set the four colors used in the progress animation. The first color will 1.538 + * also be the color of the bar that grows in response to a user swipe 1.539 + * gesture. 1.540 + * 1.541 + * @param color1 Integer representation of a color. 1.542 + * @param color2 Integer representation of a color. 1.543 + * @param color3 Integer representation of a color. 1.544 + * @param color4 Integer representation of a color. 1.545 + */ 1.546 + void setColorScheme(int color1, int color2, int color3, int color4) { 1.547 + mColor1 = color1; 1.548 + mColor2 = color2; 1.549 + mColor3 = color3; 1.550 + mColor4 = color4; 1.551 + } 1.552 + 1.553 + /** 1.554 + * Update the progress the user has made toward triggering the swipe 1.555 + * gesture. and use this value to update the percentage of the trigger that 1.556 + * is shown. 1.557 + */ 1.558 + void setTriggerPercentage(float triggerPercentage) { 1.559 + mTriggerPercentage = triggerPercentage; 1.560 + mStartTime = 0; 1.561 + ViewCompat.postInvalidateOnAnimation(mParent); 1.562 + } 1.563 + 1.564 + /** 1.565 + * Start showing the progress animation. 1.566 + */ 1.567 + void start() { 1.568 + if (!mRunning) { 1.569 + mTriggerPercentage = 0; 1.570 + mStartTime = AnimationUtils.currentAnimationTimeMillis(); 1.571 + mRunning = true; 1.572 + mParent.postInvalidate(); 1.573 + } 1.574 + } 1.575 + 1.576 + /** 1.577 + * Stop showing the progress animation. 1.578 + */ 1.579 + void stop() { 1.580 + if (mRunning) { 1.581 + mTriggerPercentage = 0; 1.582 + mFinishTime = AnimationUtils.currentAnimationTimeMillis(); 1.583 + mRunning = false; 1.584 + mParent.postInvalidate(); 1.585 + } 1.586 + } 1.587 + 1.588 + /** 1.589 + * @return Return whether the progress animation is currently running. 1.590 + */ 1.591 + boolean isRunning() { 1.592 + return mRunning || mFinishTime > 0; 1.593 + } 1.594 + 1.595 + void draw(Canvas canvas) { 1.596 + final int width = mBounds.width(); 1.597 + final int height = mBounds.height(); 1.598 + final int cx = width / 2; 1.599 + final int cy = height / 2; 1.600 + boolean drawTriggerWhileFinishing = false; 1.601 + int restoreCount = canvas.save(); 1.602 + canvas.clipRect(mBounds); 1.603 + 1.604 + if (mRunning || (mFinishTime > 0)) { 1.605 + long now = AnimationUtils.currentAnimationTimeMillis(); 1.606 + long elapsed = (now - mStartTime) % ANIMATION_DURATION_MS; 1.607 + long iterations = (now - mStartTime) / ANIMATION_DURATION_MS; 1.608 + float rawProgress = (elapsed / (ANIMATION_DURATION_MS / 100f)); 1.609 + 1.610 + // If we're not running anymore, that means we're running through 1.611 + // the finish animation. 1.612 + if (!mRunning) { 1.613 + // If the finish animation is done, don't draw anything, and 1.614 + // don't repost. 1.615 + if ((now - mFinishTime) >= FINISH_ANIMATION_DURATION_MS) { 1.616 + mFinishTime = 0; 1.617 + return; 1.618 + } 1.619 + 1.620 + // Otherwise, use a 0 opacity alpha layer to clear the animation 1.621 + // from the inside out. This layer will prevent the circles from 1.622 + // drawing within its bounds. 1.623 + long finishElapsed = (now - mFinishTime) % FINISH_ANIMATION_DURATION_MS; 1.624 + float finishProgress = (finishElapsed / (FINISH_ANIMATION_DURATION_MS / 100f)); 1.625 + float pct = (finishProgress / 100f); 1.626 + // Radius of the circle is half of the screen. 1.627 + float clearRadius = width / 2 * INTERPOLATOR.getInterpolation(pct); 1.628 + mClipRect.set(cx - clearRadius, 0, cx + clearRadius, height); 1.629 + canvas.saveLayerAlpha(mClipRect, 0, 0); 1.630 + // Only draw the trigger if there is a space in the center of 1.631 + // this refreshing view that needs to be filled in by the 1.632 + // trigger. If the progress view is just still animating, let it 1.633 + // continue animating. 1.634 + drawTriggerWhileFinishing = true; 1.635 + } 1.636 + 1.637 + // First fill in with the last color that would have finished drawing. 1.638 + if (iterations == 0) { 1.639 + canvas.drawColor(mColor1); 1.640 + } else { 1.641 + if (rawProgress >= 0 && rawProgress < 25) { 1.642 + canvas.drawColor(mColor4); 1.643 + } else if (rawProgress >= 25 && rawProgress < 50) { 1.644 + canvas.drawColor(mColor1); 1.645 + } else if (rawProgress >= 50 && rawProgress < 75) { 1.646 + canvas.drawColor(mColor2); 1.647 + } else { 1.648 + canvas.drawColor(mColor3); 1.649 + } 1.650 + } 1.651 + 1.652 + // Then draw up to 4 overlapping concentric circles of varying radii, based on how far 1.653 + // along we are in the cycle. 1.654 + // progress 0-50 draw mColor2 1.655 + // progress 25-75 draw mColor3 1.656 + // progress 50-100 draw mColor4 1.657 + // progress 75 (wrap to 25) draw mColor1 1.658 + if ((rawProgress >= 0 && rawProgress <= 25)) { 1.659 + float pct = (((rawProgress + 25) * 2) / 100f); 1.660 + drawCircle(canvas, cx, cy, mColor1, pct); 1.661 + } 1.662 + if (rawProgress >= 0 && rawProgress <= 50) { 1.663 + float pct = ((rawProgress * 2) / 100f); 1.664 + drawCircle(canvas, cx, cy, mColor2, pct); 1.665 + } 1.666 + if (rawProgress >= 25 && rawProgress <= 75) { 1.667 + float pct = (((rawProgress - 25) * 2) / 100f); 1.668 + drawCircle(canvas, cx, cy, mColor3, pct); 1.669 + } 1.670 + if (rawProgress >= 50 && rawProgress <= 100) { 1.671 + float pct = (((rawProgress - 50) * 2) / 100f); 1.672 + drawCircle(canvas, cx, cy, mColor4, pct); 1.673 + } 1.674 + if ((rawProgress >= 75 && rawProgress <= 100)) { 1.675 + float pct = (((rawProgress - 75) * 2) / 100f); 1.676 + drawCircle(canvas, cx, cy, mColor1, pct); 1.677 + } 1.678 + if (mTriggerPercentage > 0 && drawTriggerWhileFinishing) { 1.679 + // There is some portion of trigger to draw. Restore the canvas, 1.680 + // then draw the trigger. Otherwise, the trigger does not appear 1.681 + // until after the bar has finished animating and appears to 1.682 + // just jump in at a larger width than expected. 1.683 + canvas.restoreToCount(restoreCount); 1.684 + restoreCount = canvas.save(); 1.685 + canvas.clipRect(mBounds); 1.686 + drawTrigger(canvas, cx, cy); 1.687 + } 1.688 + // Keep running until we finish out the last cycle. 1.689 + ViewCompat.postInvalidateOnAnimation(mParent); 1.690 + } else { 1.691 + // Otherwise if we're in the middle of a trigger, draw that. 1.692 + if (mTriggerPercentage > 0 && mTriggerPercentage <= 1.0) { 1.693 + drawTrigger(canvas, cx, cy); 1.694 + } 1.695 + } 1.696 + canvas.restoreToCount(restoreCount); 1.697 + } 1.698 + 1.699 + private void drawTrigger(Canvas canvas, int cx, int cy) { 1.700 + mPaint.setColor(mColor1); 1.701 + // Use a rectangle to instead of a circle as per UX. 1.702 + // canvas.drawCircle(cx, cy, cx * mTriggerPercentage, mPaint); 1.703 + canvas.drawRect(cx - cx * mTriggerPercentage, 0, cx + cx * mTriggerPercentage, 1.704 + mBounds.bottom, mPaint); 1.705 + } 1.706 + 1.707 + /** 1.708 + * Draws a circle centered in the view. 1.709 + * 1.710 + * @param canvas the canvas to draw on 1.711 + * @param cx the center x coordinate 1.712 + * @param cy the center y coordinate 1.713 + * @param color the color to draw 1.714 + * @param pct the percentage of the view that the circle should cover 1.715 + */ 1.716 + private void drawCircle(Canvas canvas, float cx, float cy, int color, float pct) { 1.717 + mPaint.setColor(color); 1.718 + canvas.save(); 1.719 + canvas.translate(cx, cy); 1.720 + float radiusScale = INTERPOLATOR.getInterpolation(pct); 1.721 + canvas.scale(radiusScale, radiusScale); 1.722 + canvas.drawCircle(0, 0, cx, mPaint); 1.723 + canvas.restore(); 1.724 + } 1.725 + 1.726 + /** 1.727 + * Set the drawing bounds of this SwipeProgressBar. 1.728 + */ 1.729 + void setBounds(int left, int top, int right, int bottom) { 1.730 + mBounds.left = left; 1.731 + mBounds.top = top; 1.732 + mBounds.right = right; 1.733 + mBounds.bottom = bottom; 1.734 + } 1.735 + } 1.736 + 1.737 + private static final class BakedBezierInterpolator implements Interpolator { 1.738 + private static final BakedBezierInterpolator INSTANCE = new BakedBezierInterpolator(); 1.739 + 1.740 + public final static BakedBezierInterpolator getInstance() { 1.741 + return INSTANCE; 1.742 + } 1.743 + 1.744 + /** 1.745 + * Use getInstance instead of instantiating. 1.746 + */ 1.747 + private BakedBezierInterpolator() { 1.748 + super(); 1.749 + } 1.750 + 1.751 + /** 1.752 + * Lookup table values. 1.753 + * Generated using a Bezier curve from (0,0) to (1,1) with control points: 1.754 + * P0 (0,0) 1.755 + * P1 (0.4, 0) 1.756 + * P2 (0.2, 1.0) 1.757 + * P3 (1.0, 1.0) 1.758 + * 1.759 + * Values sampled with x at regular intervals between 0 and 1. 1.760 + */ 1.761 + private static final float[] VALUES = new float[] { 1.762 + 0.0f, 0.0002f, 0.0009f, 0.0019f, 0.0036f, 0.0059f, 0.0086f, 0.0119f, 0.0157f, 0.0209f, 1.763 + 0.0257f, 0.0321f, 0.0392f, 0.0469f, 0.0566f, 0.0656f, 0.0768f, 0.0887f, 0.1033f, 0.1186f, 1.764 + 0.1349f, 0.1519f, 0.1696f, 0.1928f, 0.2121f, 0.237f, 0.2627f, 0.2892f, 0.3109f, 0.3386f, 1.765 + 0.3667f, 0.3952f, 0.4241f, 0.4474f, 0.4766f, 0.5f, 0.5234f, 0.5468f, 0.5701f, 0.5933f, 1.766 + 0.6134f, 0.6333f, 0.6531f, 0.6698f, 0.6891f, 0.7054f, 0.7214f, 0.7346f, 0.7502f, 0.763f, 1.767 + 0.7756f, 0.7879f, 0.8f, 0.8107f, 0.8212f, 0.8326f, 0.8415f, 0.8503f, 0.8588f, 0.8672f, 1.768 + 0.8754f, 0.8833f, 0.8911f, 0.8977f, 0.9041f, 0.9113f, 0.9165f, 0.9232f, 0.9281f, 0.9328f, 1.769 + 0.9382f, 0.9434f, 0.9476f, 0.9518f, 0.9557f, 0.9596f, 0.9632f, 0.9662f, 0.9695f, 0.9722f, 1.770 + 0.9753f, 0.9777f, 0.9805f, 0.9826f, 0.9847f, 0.9866f, 0.9884f, 0.9901f, 0.9917f, 0.9931f, 1.771 + 0.9944f, 0.9955f, 0.9964f, 0.9973f, 0.9981f, 0.9986f, 0.9992f, 0.9995f, 0.9998f, 1.0f, 1.0f 1.772 + }; 1.773 + 1.774 + private static final float STEP_SIZE = 1.0f / (VALUES.length - 1); 1.775 + 1.776 + @Override 1.777 + public float getInterpolation(float input) { 1.778 + if (input >= 1.0f) { 1.779 + return 1.0f; 1.780 + } 1.781 + 1.782 + if (input <= 0f) { 1.783 + return 0f; 1.784 + } 1.785 + 1.786 + int position = Math.min( 1.787 + (int)(input * (VALUES.length - 1)), 1.788 + VALUES.length - 2); 1.789 + 1.790 + float quantized = position * STEP_SIZE; 1.791 + float difference = input - quantized; 1.792 + float weight = difference / STEP_SIZE; 1.793 + 1.794 + return VALUES[position] + weight * (VALUES[position + 1] - VALUES[position]); 1.795 + } 1.796 + 1.797 + } 1.798 +}