mobile/android/base/widget/GeckoSwipeRefreshLayout.java

changeset 0
6474c204b198
     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 +}

mercurial