|
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 |
|
17 package org.mozilla.gecko.widget; |
|
18 |
|
19 import android.content.Context; |
|
20 import android.content.res.Resources; |
|
21 import android.content.res.TypedArray; |
|
22 import android.graphics.Canvas; |
|
23 import android.graphics.Paint; |
|
24 import android.graphics.Rect; |
|
25 import android.graphics.RectF; |
|
26 import android.support.v4.view.ViewCompat; |
|
27 import android.util.AttributeSet; |
|
28 import android.util.DisplayMetrics; |
|
29 import android.view.MotionEvent; |
|
30 import android.view.View; |
|
31 import android.view.ViewConfiguration; |
|
32 import android.view.ViewGroup; |
|
33 import android.view.animation.AccelerateInterpolator; |
|
34 import android.view.animation.Animation; |
|
35 import android.view.animation.Animation.AnimationListener; |
|
36 import android.view.animation.AnimationUtils; |
|
37 import android.view.animation.DecelerateInterpolator; |
|
38 import android.view.animation.Interpolator; |
|
39 import android.view.animation.Transformation; |
|
40 import android.widget.AbsListView; |
|
41 |
|
42 /** |
|
43 * GeckoSwipeRefreshLayout is mostly lifted from Android's support library (v4) with these |
|
44 * modfications: |
|
45 * - Removes elastic "rubber banding" effect when overscrolling the child view. |
|
46 * - Changes the height of the progress bar to match the height of HomePager's page indicator. |
|
47 * - Uses a rectangle rather than a circle for the SwipeProgressBar indicator. |
|
48 * |
|
49 * This class also embeds package-access dependent classes SwipeProgressBar and |
|
50 * BakedBezierInterpolator. |
|
51 * |
|
52 * Original source: https://android.googlesource.com/platform/frameworks/support/+/ |
|
53 * android-support-lib-19.1.0/v4/java/android/support/v4/widget/SwipeRefreshLayout.java |
|
54 */ |
|
55 public class GeckoSwipeRefreshLayout extends ViewGroup { |
|
56 private static final long RETURN_TO_ORIGINAL_POSITION_TIMEOUT = 300; |
|
57 private static final float ACCELERATE_INTERPOLATION_FACTOR = 1.5f; |
|
58 private static final float DECELERATE_INTERPOLATION_FACTOR = 2f; |
|
59 // Reduce the height (from 4 to 3) of the progress bar to match HomePager's page indicator. |
|
60 private static final float PROGRESS_BAR_HEIGHT = 3; |
|
61 private static final float MAX_SWIPE_DISTANCE_FACTOR = .6f; |
|
62 private static final int REFRESH_TRIGGER_DISTANCE = 120; |
|
63 |
|
64 private SwipeProgressBar mProgressBar; //the thing that shows progress is going |
|
65 private View mTarget; //the content that gets pulled down |
|
66 private int mOriginalOffsetTop; |
|
67 private OnRefreshListener mListener; |
|
68 private MotionEvent mDownEvent; |
|
69 private int mFrom; |
|
70 private boolean mRefreshing = false; |
|
71 private int mTouchSlop; |
|
72 private float mDistanceToTriggerSync = -1; |
|
73 private float mPrevY; |
|
74 private int mMediumAnimationDuration; |
|
75 private float mFromPercentage = 0; |
|
76 private float mCurrPercentage = 0; |
|
77 private int mProgressBarHeight; |
|
78 private int mCurrentTargetOffsetTop; |
|
79 // Target is returning to its start offset because it was cancelled or a |
|
80 // refresh was triggered. |
|
81 private boolean mReturningToStart; |
|
82 private final DecelerateInterpolator mDecelerateInterpolator; |
|
83 private final AccelerateInterpolator mAccelerateInterpolator; |
|
84 private static final int[] LAYOUT_ATTRS = new int[] { |
|
85 android.R.attr.enabled |
|
86 }; |
|
87 |
|
88 private final Animation mAnimateToStartPosition = new Animation() { |
|
89 @Override |
|
90 public void applyTransformation(float interpolatedTime, Transformation t) { |
|
91 int targetTop = 0; |
|
92 if (mFrom != mOriginalOffsetTop) { |
|
93 targetTop = (mFrom + (int)((mOriginalOffsetTop - mFrom) * interpolatedTime)); |
|
94 } |
|
95 int offset = targetTop - mTarget.getTop(); |
|
96 final int currentTop = mTarget.getTop(); |
|
97 if (offset + currentTop < 0) { |
|
98 offset = 0 - currentTop; |
|
99 } |
|
100 setTargetOffsetTopAndBottom(offset); |
|
101 } |
|
102 }; |
|
103 |
|
104 private Animation mShrinkTrigger = new Animation() { |
|
105 @Override |
|
106 public void applyTransformation(float interpolatedTime, Transformation t) { |
|
107 float percent = mFromPercentage + ((0 - mFromPercentage) * interpolatedTime); |
|
108 mProgressBar.setTriggerPercentage(percent); |
|
109 } |
|
110 }; |
|
111 |
|
112 private final AnimationListener mReturnToStartPositionListener = new BaseAnimationListener() { |
|
113 @Override |
|
114 public void onAnimationEnd(Animation animation) { |
|
115 // Once the target content has returned to its start position, reset |
|
116 // the target offset to 0 |
|
117 mCurrentTargetOffsetTop = 0; |
|
118 } |
|
119 }; |
|
120 |
|
121 private final AnimationListener mShrinkAnimationListener = new BaseAnimationListener() { |
|
122 @Override |
|
123 public void onAnimationEnd(Animation animation) { |
|
124 mCurrPercentage = 0; |
|
125 } |
|
126 }; |
|
127 |
|
128 private final Runnable mReturnToStartPosition = new Runnable() { |
|
129 |
|
130 @Override |
|
131 public void run() { |
|
132 mReturningToStart = true; |
|
133 animateOffsetToStartPosition(mCurrentTargetOffsetTop + getPaddingTop(), |
|
134 mReturnToStartPositionListener); |
|
135 } |
|
136 |
|
137 }; |
|
138 |
|
139 // Cancel the refresh gesture and animate everything back to its original state. |
|
140 private final Runnable mCancel = new Runnable() { |
|
141 |
|
142 @Override |
|
143 public void run() { |
|
144 mReturningToStart = true; |
|
145 // Timeout fired since the user last moved their finger; animate the |
|
146 // trigger to 0 and put the target back at its original position |
|
147 if (mProgressBar != null) { |
|
148 mFromPercentage = mCurrPercentage; |
|
149 mShrinkTrigger.setDuration(mMediumAnimationDuration); |
|
150 mShrinkTrigger.setAnimationListener(mShrinkAnimationListener); |
|
151 mShrinkTrigger.reset(); |
|
152 mShrinkTrigger.setInterpolator(mDecelerateInterpolator); |
|
153 startAnimation(mShrinkTrigger); |
|
154 } |
|
155 animateOffsetToStartPosition(mCurrentTargetOffsetTop + getPaddingTop(), |
|
156 mReturnToStartPositionListener); |
|
157 } |
|
158 |
|
159 }; |
|
160 |
|
161 /** |
|
162 * Simple constructor to use when creating a GeckoSwipeRefreshLayout from code. |
|
163 * @param context |
|
164 */ |
|
165 public GeckoSwipeRefreshLayout(Context context) { |
|
166 this(context, null); |
|
167 } |
|
168 |
|
169 /** |
|
170 * Constructor that is called when inflating GeckoSwipeRefreshLayout from XML. |
|
171 * @param context |
|
172 * @param attrs |
|
173 */ |
|
174 public GeckoSwipeRefreshLayout(Context context, AttributeSet attrs) { |
|
175 super(context, attrs); |
|
176 |
|
177 mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); |
|
178 |
|
179 mMediumAnimationDuration = getResources().getInteger( |
|
180 android.R.integer.config_mediumAnimTime); |
|
181 |
|
182 setWillNotDraw(false); |
|
183 mProgressBar = new SwipeProgressBar(this); |
|
184 final DisplayMetrics metrics = getResources().getDisplayMetrics(); |
|
185 mProgressBarHeight = (int) (metrics.density * PROGRESS_BAR_HEIGHT); |
|
186 mDecelerateInterpolator = new DecelerateInterpolator(DECELERATE_INTERPOLATION_FACTOR); |
|
187 mAccelerateInterpolator = new AccelerateInterpolator(ACCELERATE_INTERPOLATION_FACTOR); |
|
188 |
|
189 final TypedArray a = context.obtainStyledAttributes(attrs, LAYOUT_ATTRS); |
|
190 setEnabled(a.getBoolean(0, true)); |
|
191 a.recycle(); |
|
192 } |
|
193 |
|
194 @Override |
|
195 public void onAttachedToWindow() { |
|
196 super.onAttachedToWindow(); |
|
197 removeCallbacks(mCancel); |
|
198 removeCallbacks(mReturnToStartPosition); |
|
199 } |
|
200 |
|
201 @Override |
|
202 public void onDetachedFromWindow() { |
|
203 super.onDetachedFromWindow(); |
|
204 removeCallbacks(mReturnToStartPosition); |
|
205 removeCallbacks(mCancel); |
|
206 } |
|
207 |
|
208 private void animateOffsetToStartPosition(int from, AnimationListener listener) { |
|
209 mFrom = from; |
|
210 mAnimateToStartPosition.reset(); |
|
211 mAnimateToStartPosition.setDuration(mMediumAnimationDuration); |
|
212 mAnimateToStartPosition.setAnimationListener(listener); |
|
213 mAnimateToStartPosition.setInterpolator(mDecelerateInterpolator); |
|
214 mTarget.startAnimation(mAnimateToStartPosition); |
|
215 } |
|
216 |
|
217 /** |
|
218 * Set the listener to be notified when a refresh is triggered via the swipe |
|
219 * gesture. |
|
220 */ |
|
221 public void setOnRefreshListener(OnRefreshListener listener) { |
|
222 mListener = listener; |
|
223 } |
|
224 |
|
225 private void setTriggerPercentage(float percent) { |
|
226 if (percent == 0f) { |
|
227 // No-op. A null trigger means it's uninitialized, and setting it to zero-percent |
|
228 // means we're trying to reset state, so there's nothing to reset in this case. |
|
229 mCurrPercentage = 0; |
|
230 return; |
|
231 } |
|
232 mCurrPercentage = percent; |
|
233 mProgressBar.setTriggerPercentage(percent); |
|
234 } |
|
235 |
|
236 /** |
|
237 * Notify the widget that refresh state has changed. Do not call this when |
|
238 * refresh is triggered by a swipe gesture. |
|
239 * |
|
240 * @param refreshing Whether or not the view should show refresh progress. |
|
241 */ |
|
242 public void setRefreshing(boolean refreshing) { |
|
243 if (mRefreshing != refreshing) { |
|
244 ensureTarget(); |
|
245 mCurrPercentage = 0; |
|
246 mRefreshing = refreshing; |
|
247 if (mRefreshing) { |
|
248 mProgressBar.start(); |
|
249 } else { |
|
250 mProgressBar.stop(); |
|
251 } |
|
252 } |
|
253 } |
|
254 |
|
255 /** |
|
256 * Set the four colors used in the progress animation. The first color will |
|
257 * also be the color of the bar that grows in response to a user swipe |
|
258 * gesture. |
|
259 * |
|
260 * @param colorRes1 Color resource. |
|
261 * @param colorRes2 Color resource. |
|
262 * @param colorRes3 Color resource. |
|
263 * @param colorRes4 Color resource. |
|
264 */ |
|
265 public void setColorScheme(int colorRes1, int colorRes2, int colorRes3, int colorRes4) { |
|
266 ensureTarget(); |
|
267 final Resources res = getResources(); |
|
268 final int color1 = res.getColor(colorRes1); |
|
269 final int color2 = res.getColor(colorRes2); |
|
270 final int color3 = res.getColor(colorRes3); |
|
271 final int color4 = res.getColor(colorRes4); |
|
272 mProgressBar.setColorScheme(color1, color2, color3,color4); |
|
273 } |
|
274 |
|
275 /** |
|
276 * @return Whether the SwipeRefreshWidget is actively showing refresh |
|
277 * progress. |
|
278 */ |
|
279 public boolean isRefreshing() { |
|
280 return mRefreshing; |
|
281 } |
|
282 |
|
283 private void ensureTarget() { |
|
284 // Don't bother getting the parent height if the parent hasn't been laid out yet. |
|
285 if (mTarget == null) { |
|
286 if (getChildCount() > 1 && !isInEditMode()) { |
|
287 throw new IllegalStateException( |
|
288 "GeckoSwipeRefreshLayout can host only one direct child"); |
|
289 } |
|
290 mTarget = getChildAt(0); |
|
291 mOriginalOffsetTop = mTarget.getTop() + getPaddingTop(); |
|
292 } |
|
293 if (mDistanceToTriggerSync == -1) { |
|
294 if (getParent() != null && ((View)getParent()).getHeight() > 0) { |
|
295 final DisplayMetrics metrics = getResources().getDisplayMetrics(); |
|
296 mDistanceToTriggerSync = (int) Math.min( |
|
297 ((View) getParent()) .getHeight() * MAX_SWIPE_DISTANCE_FACTOR, |
|
298 REFRESH_TRIGGER_DISTANCE * metrics.density); |
|
299 } |
|
300 } |
|
301 } |
|
302 |
|
303 @Override |
|
304 public void draw(Canvas canvas) { |
|
305 super.draw(canvas); |
|
306 mProgressBar.draw(canvas); |
|
307 } |
|
308 |
|
309 @Override |
|
310 protected void onLayout(boolean changed, int left, int top, int right, int bottom) { |
|
311 final int width = getMeasuredWidth(); |
|
312 final int height = getMeasuredHeight(); |
|
313 mProgressBar.setBounds(0, 0, width, mProgressBarHeight); |
|
314 if (getChildCount() == 0) { |
|
315 return; |
|
316 } |
|
317 final View child = getChildAt(0); |
|
318 final int childLeft = getPaddingLeft(); |
|
319 final int childTop = mCurrentTargetOffsetTop + getPaddingTop(); |
|
320 final int childWidth = width - getPaddingLeft() - getPaddingRight(); |
|
321 final int childHeight = height - getPaddingTop() - getPaddingBottom(); |
|
322 child.layout(childLeft, childTop, childLeft + childWidth, childTop + childHeight); |
|
323 } |
|
324 |
|
325 @Override |
|
326 public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { |
|
327 super.onMeasure(widthMeasureSpec, heightMeasureSpec); |
|
328 if (getChildCount() > 1 && !isInEditMode()) { |
|
329 throw new IllegalStateException("GeckoSwipeRefreshLayout can host only one direct child"); |
|
330 } |
|
331 if (getChildCount() > 0) { |
|
332 getChildAt(0).measure( |
|
333 MeasureSpec.makeMeasureSpec( |
|
334 getMeasuredWidth() - getPaddingLeft() - getPaddingRight(), |
|
335 MeasureSpec.EXACTLY), |
|
336 MeasureSpec.makeMeasureSpec( |
|
337 getMeasuredHeight() - getPaddingTop() - getPaddingBottom(), |
|
338 MeasureSpec.EXACTLY)); |
|
339 } |
|
340 } |
|
341 |
|
342 /** |
|
343 * @return Whether it is possible for the child view of this layout to |
|
344 * scroll up. Override this if the child view is a custom view. |
|
345 */ |
|
346 public boolean canChildScrollUp() { |
|
347 if (android.os.Build.VERSION.SDK_INT < 14) { |
|
348 if (mTarget instanceof AbsListView) { |
|
349 final AbsListView absListView = (AbsListView) mTarget; |
|
350 return absListView.getChildCount() > 0 |
|
351 && (absListView.getFirstVisiblePosition() > 0 || absListView.getChildAt(0) |
|
352 .getTop() < absListView.getPaddingTop()); |
|
353 } else { |
|
354 return mTarget.getScrollY() > 0; |
|
355 } |
|
356 } else { |
|
357 return ViewCompat.canScrollVertically(mTarget, -1); |
|
358 } |
|
359 } |
|
360 |
|
361 @Override |
|
362 public boolean onInterceptTouchEvent(MotionEvent ev) { |
|
363 ensureTarget(); |
|
364 boolean handled = false; |
|
365 if (mReturningToStart && ev.getAction() == MotionEvent.ACTION_DOWN) { |
|
366 mReturningToStart = false; |
|
367 } |
|
368 if (isEnabled() && !mReturningToStart && !canChildScrollUp()) { |
|
369 handled = onTouchEvent(ev); |
|
370 } |
|
371 return !handled ? super.onInterceptTouchEvent(ev) : handled; |
|
372 } |
|
373 |
|
374 @Override |
|
375 public void requestDisallowInterceptTouchEvent(boolean b) { |
|
376 // Nope. |
|
377 } |
|
378 |
|
379 @Override |
|
380 public boolean onTouchEvent(MotionEvent event) { |
|
381 final int action = event.getAction(); |
|
382 boolean handled = false; |
|
383 switch (action) { |
|
384 case MotionEvent.ACTION_DOWN: |
|
385 mCurrPercentage = 0; |
|
386 mDownEvent = MotionEvent.obtain(event); |
|
387 mPrevY = mDownEvent.getY(); |
|
388 break; |
|
389 case MotionEvent.ACTION_MOVE: |
|
390 if (mDownEvent != null && !mReturningToStart) { |
|
391 final float eventY = event.getY(); |
|
392 float yDiff = eventY - mDownEvent.getY(); |
|
393 if (yDiff > mTouchSlop) { |
|
394 // User velocity passed min velocity; trigger a refresh |
|
395 if (yDiff > mDistanceToTriggerSync) { |
|
396 // User movement passed distance; trigger a refresh |
|
397 startRefresh(); |
|
398 handled = true; |
|
399 break; |
|
400 } else { |
|
401 // Just track the user's movement |
|
402 setTriggerPercentage( |
|
403 mAccelerateInterpolator.getInterpolation( |
|
404 yDiff / mDistanceToTriggerSync)); |
|
405 float offsetTop = yDiff; |
|
406 if (mPrevY > eventY) { |
|
407 offsetTop = yDiff - mTouchSlop; |
|
408 } |
|
409 // Removed this call to disable "rubber-band" overscroll effect. |
|
410 // updateContentOffsetTop((int) offsetTop); |
|
411 if (mPrevY > eventY && (mTarget.getTop() < mTouchSlop)) { |
|
412 // If the user puts the view back at the top, we |
|
413 // don't need to. This shouldn't be considered |
|
414 // cancelling the gesture as the user can restart from the top. |
|
415 removeCallbacks(mCancel); |
|
416 } else { |
|
417 updatePositionTimeout(); |
|
418 } |
|
419 mPrevY = event.getY(); |
|
420 handled = true; |
|
421 } |
|
422 } |
|
423 } |
|
424 break; |
|
425 case MotionEvent.ACTION_UP: |
|
426 case MotionEvent.ACTION_CANCEL: |
|
427 if (mDownEvent != null) { |
|
428 mDownEvent.recycle(); |
|
429 mDownEvent = null; |
|
430 } |
|
431 break; |
|
432 } |
|
433 return handled; |
|
434 } |
|
435 |
|
436 private void startRefresh() { |
|
437 removeCallbacks(mCancel); |
|
438 mReturnToStartPosition.run(); |
|
439 setRefreshing(true); |
|
440 mListener.onRefresh(); |
|
441 } |
|
442 |
|
443 private void updateContentOffsetTop(int targetTop) { |
|
444 final int currentTop = mTarget.getTop(); |
|
445 if (targetTop > mDistanceToTriggerSync) { |
|
446 targetTop = (int) mDistanceToTriggerSync; |
|
447 } else if (targetTop < 0) { |
|
448 targetTop = 0; |
|
449 } |
|
450 setTargetOffsetTopAndBottom(targetTop - currentTop); |
|
451 } |
|
452 |
|
453 private void setTargetOffsetTopAndBottom(int offset) { |
|
454 mTarget.offsetTopAndBottom(offset); |
|
455 mCurrentTargetOffsetTop = mTarget.getTop(); |
|
456 } |
|
457 |
|
458 private void updatePositionTimeout() { |
|
459 removeCallbacks(mCancel); |
|
460 postDelayed(mCancel, RETURN_TO_ORIGINAL_POSITION_TIMEOUT); |
|
461 } |
|
462 |
|
463 /** |
|
464 * Classes that wish to be notified when the swipe gesture correctly |
|
465 * triggers a refresh should implement this interface. |
|
466 */ |
|
467 public interface OnRefreshListener { |
|
468 public void onRefresh(); |
|
469 } |
|
470 |
|
471 /** |
|
472 * Simple AnimationListener to avoid having to implement unneeded methods in |
|
473 * AnimationListeners. |
|
474 */ |
|
475 private class BaseAnimationListener implements AnimationListener { |
|
476 @Override |
|
477 public void onAnimationStart(Animation animation) { |
|
478 } |
|
479 |
|
480 @Override |
|
481 public void onAnimationEnd(Animation animation) { |
|
482 } |
|
483 |
|
484 @Override |
|
485 public void onAnimationRepeat(Animation animation) { |
|
486 } |
|
487 } |
|
488 |
|
489 /** |
|
490 * The only modification to this class is the shape of the refresh indicator to be a rectangle |
|
491 * rather than a circle. |
|
492 */ |
|
493 private static final class SwipeProgressBar { |
|
494 // Default progress animation colors are grays. |
|
495 private final static int COLOR1 = 0xB3000000; |
|
496 private final static int COLOR2 = 0x80000000; |
|
497 private final static int COLOR3 = 0x4d000000; |
|
498 private final static int COLOR4 = 0x1a000000; |
|
499 |
|
500 // The duration of the animation cycle. |
|
501 private static final int ANIMATION_DURATION_MS = 2000; |
|
502 |
|
503 // The duration of the animation to clear the bar. |
|
504 private static final int FINISH_ANIMATION_DURATION_MS = 1000; |
|
505 |
|
506 // Interpolator for varying the speed of the animation. |
|
507 private static final Interpolator INTERPOLATOR = BakedBezierInterpolator.getInstance(); |
|
508 |
|
509 private final Paint mPaint = new Paint(); |
|
510 private final RectF mClipRect = new RectF(); |
|
511 private float mTriggerPercentage; |
|
512 private long mStartTime; |
|
513 private long mFinishTime; |
|
514 private boolean mRunning; |
|
515 |
|
516 // Colors used when rendering the animation, |
|
517 private int mColor1; |
|
518 private int mColor2; |
|
519 private int mColor3; |
|
520 private int mColor4; |
|
521 private View mParent; |
|
522 |
|
523 private Rect mBounds = new Rect(); |
|
524 |
|
525 public SwipeProgressBar(View parent) { |
|
526 mParent = parent; |
|
527 mColor1 = COLOR1; |
|
528 mColor2 = COLOR2; |
|
529 mColor3 = COLOR3; |
|
530 mColor4 = COLOR4; |
|
531 } |
|
532 |
|
533 /** |
|
534 * Set the four colors used in the progress animation. The first color will |
|
535 * also be the color of the bar that grows in response to a user swipe |
|
536 * gesture. |
|
537 * |
|
538 * @param color1 Integer representation of a color. |
|
539 * @param color2 Integer representation of a color. |
|
540 * @param color3 Integer representation of a color. |
|
541 * @param color4 Integer representation of a color. |
|
542 */ |
|
543 void setColorScheme(int color1, int color2, int color3, int color4) { |
|
544 mColor1 = color1; |
|
545 mColor2 = color2; |
|
546 mColor3 = color3; |
|
547 mColor4 = color4; |
|
548 } |
|
549 |
|
550 /** |
|
551 * Update the progress the user has made toward triggering the swipe |
|
552 * gesture. and use this value to update the percentage of the trigger that |
|
553 * is shown. |
|
554 */ |
|
555 void setTriggerPercentage(float triggerPercentage) { |
|
556 mTriggerPercentage = triggerPercentage; |
|
557 mStartTime = 0; |
|
558 ViewCompat.postInvalidateOnAnimation(mParent); |
|
559 } |
|
560 |
|
561 /** |
|
562 * Start showing the progress animation. |
|
563 */ |
|
564 void start() { |
|
565 if (!mRunning) { |
|
566 mTriggerPercentage = 0; |
|
567 mStartTime = AnimationUtils.currentAnimationTimeMillis(); |
|
568 mRunning = true; |
|
569 mParent.postInvalidate(); |
|
570 } |
|
571 } |
|
572 |
|
573 /** |
|
574 * Stop showing the progress animation. |
|
575 */ |
|
576 void stop() { |
|
577 if (mRunning) { |
|
578 mTriggerPercentage = 0; |
|
579 mFinishTime = AnimationUtils.currentAnimationTimeMillis(); |
|
580 mRunning = false; |
|
581 mParent.postInvalidate(); |
|
582 } |
|
583 } |
|
584 |
|
585 /** |
|
586 * @return Return whether the progress animation is currently running. |
|
587 */ |
|
588 boolean isRunning() { |
|
589 return mRunning || mFinishTime > 0; |
|
590 } |
|
591 |
|
592 void draw(Canvas canvas) { |
|
593 final int width = mBounds.width(); |
|
594 final int height = mBounds.height(); |
|
595 final int cx = width / 2; |
|
596 final int cy = height / 2; |
|
597 boolean drawTriggerWhileFinishing = false; |
|
598 int restoreCount = canvas.save(); |
|
599 canvas.clipRect(mBounds); |
|
600 |
|
601 if (mRunning || (mFinishTime > 0)) { |
|
602 long now = AnimationUtils.currentAnimationTimeMillis(); |
|
603 long elapsed = (now - mStartTime) % ANIMATION_DURATION_MS; |
|
604 long iterations = (now - mStartTime) / ANIMATION_DURATION_MS; |
|
605 float rawProgress = (elapsed / (ANIMATION_DURATION_MS / 100f)); |
|
606 |
|
607 // If we're not running anymore, that means we're running through |
|
608 // the finish animation. |
|
609 if (!mRunning) { |
|
610 // If the finish animation is done, don't draw anything, and |
|
611 // don't repost. |
|
612 if ((now - mFinishTime) >= FINISH_ANIMATION_DURATION_MS) { |
|
613 mFinishTime = 0; |
|
614 return; |
|
615 } |
|
616 |
|
617 // Otherwise, use a 0 opacity alpha layer to clear the animation |
|
618 // from the inside out. This layer will prevent the circles from |
|
619 // drawing within its bounds. |
|
620 long finishElapsed = (now - mFinishTime) % FINISH_ANIMATION_DURATION_MS; |
|
621 float finishProgress = (finishElapsed / (FINISH_ANIMATION_DURATION_MS / 100f)); |
|
622 float pct = (finishProgress / 100f); |
|
623 // Radius of the circle is half of the screen. |
|
624 float clearRadius = width / 2 * INTERPOLATOR.getInterpolation(pct); |
|
625 mClipRect.set(cx - clearRadius, 0, cx + clearRadius, height); |
|
626 canvas.saveLayerAlpha(mClipRect, 0, 0); |
|
627 // Only draw the trigger if there is a space in the center of |
|
628 // this refreshing view that needs to be filled in by the |
|
629 // trigger. If the progress view is just still animating, let it |
|
630 // continue animating. |
|
631 drawTriggerWhileFinishing = true; |
|
632 } |
|
633 |
|
634 // First fill in with the last color that would have finished drawing. |
|
635 if (iterations == 0) { |
|
636 canvas.drawColor(mColor1); |
|
637 } else { |
|
638 if (rawProgress >= 0 && rawProgress < 25) { |
|
639 canvas.drawColor(mColor4); |
|
640 } else if (rawProgress >= 25 && rawProgress < 50) { |
|
641 canvas.drawColor(mColor1); |
|
642 } else if (rawProgress >= 50 && rawProgress < 75) { |
|
643 canvas.drawColor(mColor2); |
|
644 } else { |
|
645 canvas.drawColor(mColor3); |
|
646 } |
|
647 } |
|
648 |
|
649 // Then draw up to 4 overlapping concentric circles of varying radii, based on how far |
|
650 // along we are in the cycle. |
|
651 // progress 0-50 draw mColor2 |
|
652 // progress 25-75 draw mColor3 |
|
653 // progress 50-100 draw mColor4 |
|
654 // progress 75 (wrap to 25) draw mColor1 |
|
655 if ((rawProgress >= 0 && rawProgress <= 25)) { |
|
656 float pct = (((rawProgress + 25) * 2) / 100f); |
|
657 drawCircle(canvas, cx, cy, mColor1, pct); |
|
658 } |
|
659 if (rawProgress >= 0 && rawProgress <= 50) { |
|
660 float pct = ((rawProgress * 2) / 100f); |
|
661 drawCircle(canvas, cx, cy, mColor2, pct); |
|
662 } |
|
663 if (rawProgress >= 25 && rawProgress <= 75) { |
|
664 float pct = (((rawProgress - 25) * 2) / 100f); |
|
665 drawCircle(canvas, cx, cy, mColor3, pct); |
|
666 } |
|
667 if (rawProgress >= 50 && rawProgress <= 100) { |
|
668 float pct = (((rawProgress - 50) * 2) / 100f); |
|
669 drawCircle(canvas, cx, cy, mColor4, pct); |
|
670 } |
|
671 if ((rawProgress >= 75 && rawProgress <= 100)) { |
|
672 float pct = (((rawProgress - 75) * 2) / 100f); |
|
673 drawCircle(canvas, cx, cy, mColor1, pct); |
|
674 } |
|
675 if (mTriggerPercentage > 0 && drawTriggerWhileFinishing) { |
|
676 // There is some portion of trigger to draw. Restore the canvas, |
|
677 // then draw the trigger. Otherwise, the trigger does not appear |
|
678 // until after the bar has finished animating and appears to |
|
679 // just jump in at a larger width than expected. |
|
680 canvas.restoreToCount(restoreCount); |
|
681 restoreCount = canvas.save(); |
|
682 canvas.clipRect(mBounds); |
|
683 drawTrigger(canvas, cx, cy); |
|
684 } |
|
685 // Keep running until we finish out the last cycle. |
|
686 ViewCompat.postInvalidateOnAnimation(mParent); |
|
687 } else { |
|
688 // Otherwise if we're in the middle of a trigger, draw that. |
|
689 if (mTriggerPercentage > 0 && mTriggerPercentage <= 1.0) { |
|
690 drawTrigger(canvas, cx, cy); |
|
691 } |
|
692 } |
|
693 canvas.restoreToCount(restoreCount); |
|
694 } |
|
695 |
|
696 private void drawTrigger(Canvas canvas, int cx, int cy) { |
|
697 mPaint.setColor(mColor1); |
|
698 // Use a rectangle to instead of a circle as per UX. |
|
699 // canvas.drawCircle(cx, cy, cx * mTriggerPercentage, mPaint); |
|
700 canvas.drawRect(cx - cx * mTriggerPercentage, 0, cx + cx * mTriggerPercentage, |
|
701 mBounds.bottom, mPaint); |
|
702 } |
|
703 |
|
704 /** |
|
705 * Draws a circle centered in the view. |
|
706 * |
|
707 * @param canvas the canvas to draw on |
|
708 * @param cx the center x coordinate |
|
709 * @param cy the center y coordinate |
|
710 * @param color the color to draw |
|
711 * @param pct the percentage of the view that the circle should cover |
|
712 */ |
|
713 private void drawCircle(Canvas canvas, float cx, float cy, int color, float pct) { |
|
714 mPaint.setColor(color); |
|
715 canvas.save(); |
|
716 canvas.translate(cx, cy); |
|
717 float radiusScale = INTERPOLATOR.getInterpolation(pct); |
|
718 canvas.scale(radiusScale, radiusScale); |
|
719 canvas.drawCircle(0, 0, cx, mPaint); |
|
720 canvas.restore(); |
|
721 } |
|
722 |
|
723 /** |
|
724 * Set the drawing bounds of this SwipeProgressBar. |
|
725 */ |
|
726 void setBounds(int left, int top, int right, int bottom) { |
|
727 mBounds.left = left; |
|
728 mBounds.top = top; |
|
729 mBounds.right = right; |
|
730 mBounds.bottom = bottom; |
|
731 } |
|
732 } |
|
733 |
|
734 private static final class BakedBezierInterpolator implements Interpolator { |
|
735 private static final BakedBezierInterpolator INSTANCE = new BakedBezierInterpolator(); |
|
736 |
|
737 public final static BakedBezierInterpolator getInstance() { |
|
738 return INSTANCE; |
|
739 } |
|
740 |
|
741 /** |
|
742 * Use getInstance instead of instantiating. |
|
743 */ |
|
744 private BakedBezierInterpolator() { |
|
745 super(); |
|
746 } |
|
747 |
|
748 /** |
|
749 * Lookup table values. |
|
750 * Generated using a Bezier curve from (0,0) to (1,1) with control points: |
|
751 * P0 (0,0) |
|
752 * P1 (0.4, 0) |
|
753 * P2 (0.2, 1.0) |
|
754 * P3 (1.0, 1.0) |
|
755 * |
|
756 * Values sampled with x at regular intervals between 0 and 1. |
|
757 */ |
|
758 private static final float[] VALUES = new float[] { |
|
759 0.0f, 0.0002f, 0.0009f, 0.0019f, 0.0036f, 0.0059f, 0.0086f, 0.0119f, 0.0157f, 0.0209f, |
|
760 0.0257f, 0.0321f, 0.0392f, 0.0469f, 0.0566f, 0.0656f, 0.0768f, 0.0887f, 0.1033f, 0.1186f, |
|
761 0.1349f, 0.1519f, 0.1696f, 0.1928f, 0.2121f, 0.237f, 0.2627f, 0.2892f, 0.3109f, 0.3386f, |
|
762 0.3667f, 0.3952f, 0.4241f, 0.4474f, 0.4766f, 0.5f, 0.5234f, 0.5468f, 0.5701f, 0.5933f, |
|
763 0.6134f, 0.6333f, 0.6531f, 0.6698f, 0.6891f, 0.7054f, 0.7214f, 0.7346f, 0.7502f, 0.763f, |
|
764 0.7756f, 0.7879f, 0.8f, 0.8107f, 0.8212f, 0.8326f, 0.8415f, 0.8503f, 0.8588f, 0.8672f, |
|
765 0.8754f, 0.8833f, 0.8911f, 0.8977f, 0.9041f, 0.9113f, 0.9165f, 0.9232f, 0.9281f, 0.9328f, |
|
766 0.9382f, 0.9434f, 0.9476f, 0.9518f, 0.9557f, 0.9596f, 0.9632f, 0.9662f, 0.9695f, 0.9722f, |
|
767 0.9753f, 0.9777f, 0.9805f, 0.9826f, 0.9847f, 0.9866f, 0.9884f, 0.9901f, 0.9917f, 0.9931f, |
|
768 0.9944f, 0.9955f, 0.9964f, 0.9973f, 0.9981f, 0.9986f, 0.9992f, 0.9995f, 0.9998f, 1.0f, 1.0f |
|
769 }; |
|
770 |
|
771 private static final float STEP_SIZE = 1.0f / (VALUES.length - 1); |
|
772 |
|
773 @Override |
|
774 public float getInterpolation(float input) { |
|
775 if (input >= 1.0f) { |
|
776 return 1.0f; |
|
777 } |
|
778 |
|
779 if (input <= 0f) { |
|
780 return 0f; |
|
781 } |
|
782 |
|
783 int position = Math.min( |
|
784 (int)(input * (VALUES.length - 1)), |
|
785 VALUES.length - 2); |
|
786 |
|
787 float quantized = position * STEP_SIZE; |
|
788 float difference = input - quantized; |
|
789 float weight = difference / STEP_SIZE; |
|
790 |
|
791 return VALUES[position] + weight * (VALUES[position + 1] - VALUES[position]); |
|
792 } |
|
793 |
|
794 } |
|
795 } |