From 0804bfc88d983b5f70344214627e11fe48dbe0b4 Mon Sep 17 00:00:00 2001 From: seven332 Date: Wed, 28 Jan 2015 18:45:09 +0800 Subject: [PATCH] Add RefreshLayout --- .../BakedBezierInterpolator.java | 84 ++ .../widget/refreshlayout/CircleImageView.java | 149 +++ .../MaterialProgressDrawable.java | 714 +++++++++++ .../widget/refreshlayout/RefreshLayout.java | 1073 +++++++++++++++++ .../refreshlayout/SwipeProgressBar.java | 275 +++++ 5 files changed, 2295 insertions(+) create mode 100644 app/src/main/java/com/hippo/widget/refreshlayout/BakedBezierInterpolator.java create mode 100644 app/src/main/java/com/hippo/widget/refreshlayout/CircleImageView.java create mode 100644 app/src/main/java/com/hippo/widget/refreshlayout/MaterialProgressDrawable.java create mode 100644 app/src/main/java/com/hippo/widget/refreshlayout/RefreshLayout.java create mode 100644 app/src/main/java/com/hippo/widget/refreshlayout/SwipeProgressBar.java diff --git a/app/src/main/java/com/hippo/widget/refreshlayout/BakedBezierInterpolator.java b/app/src/main/java/com/hippo/widget/refreshlayout/BakedBezierInterpolator.java new file mode 100644 index 000000000..d5ff6329e --- /dev/null +++ b/app/src/main/java/com/hippo/widget/refreshlayout/BakedBezierInterpolator.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.widget.refreshlayout; + +import android.view.animation.Interpolator; + +/** + * A pre-baked bezier-curved interpolator for indeterminate progress animations. + */ +final class BakedBezierInterpolator implements Interpolator { + private static final BakedBezierInterpolator INSTANCE = new BakedBezierInterpolator(); + + public final static BakedBezierInterpolator getInstance() { + return INSTANCE; + } + + /** + * Use getInstance instead of instantiating. + */ + private BakedBezierInterpolator() { + super(); + } + + /** + * Lookup table values. + * Generated using a Bezier curve from (0,0) to (1,1) with control points: + * P0 (0,0) + * P1 (0.4, 0) + * P2 (0.2, 1.0) + * P3 (1.0, 1.0) + * + * Values sampled with x at regular intervals between 0 and 1. + */ + private static final float[] VALUES = new float[] { + 0.0f, 0.0002f, 0.0009f, 0.0019f, 0.0036f, 0.0059f, 0.0086f, 0.0119f, 0.0157f, 0.0209f, + 0.0257f, 0.0321f, 0.0392f, 0.0469f, 0.0566f, 0.0656f, 0.0768f, 0.0887f, 0.1033f, 0.1186f, + 0.1349f, 0.1519f, 0.1696f, 0.1928f, 0.2121f, 0.237f, 0.2627f, 0.2892f, 0.3109f, 0.3386f, + 0.3667f, 0.3952f, 0.4241f, 0.4474f, 0.4766f, 0.5f, 0.5234f, 0.5468f, 0.5701f, 0.5933f, + 0.6134f, 0.6333f, 0.6531f, 0.6698f, 0.6891f, 0.7054f, 0.7214f, 0.7346f, 0.7502f, 0.763f, + 0.7756f, 0.7879f, 0.8f, 0.8107f, 0.8212f, 0.8326f, 0.8415f, 0.8503f, 0.8588f, 0.8672f, + 0.8754f, 0.8833f, 0.8911f, 0.8977f, 0.9041f, 0.9113f, 0.9165f, 0.9232f, 0.9281f, 0.9328f, + 0.9382f, 0.9434f, 0.9476f, 0.9518f, 0.9557f, 0.9596f, 0.9632f, 0.9662f, 0.9695f, 0.9722f, + 0.9753f, 0.9777f, 0.9805f, 0.9826f, 0.9847f, 0.9866f, 0.9884f, 0.9901f, 0.9917f, 0.9931f, + 0.9944f, 0.9955f, 0.9964f, 0.9973f, 0.9981f, 0.9986f, 0.9992f, 0.9995f, 0.9998f, 1.0f, 1.0f + }; + + private static final float STEP_SIZE = 1.0f / (VALUES.length - 1); + + @Override + public float getInterpolation(float input) { + if (input >= 1.0f) { + return 1.0f; + } + + if (input <= 0f) { + return 0f; + } + + int position = Math.min( + (int)(input * (VALUES.length - 1)), + VALUES.length - 2); + + float quantized = position * STEP_SIZE; + float difference = input - quantized; + float weight = difference / STEP_SIZE; + + return VALUES[position] + weight * (VALUES[position + 1] - VALUES[position]); + } + +} diff --git a/app/src/main/java/com/hippo/widget/refreshlayout/CircleImageView.java b/app/src/main/java/com/hippo/widget/refreshlayout/CircleImageView.java new file mode 100644 index 000000000..16d9dc388 --- /dev/null +++ b/app/src/main/java/com/hippo/widget/refreshlayout/CircleImageView.java @@ -0,0 +1,149 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.widget.refreshlayout; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.RadialGradient; +import android.graphics.Shader; +import android.graphics.drawable.ShapeDrawable; +import android.graphics.drawable.shapes.OvalShape; +import android.support.v4.view.ViewCompat; +import android.view.animation.Animation; +import android.widget.ImageView; + +/** + * Private class created to work around issues with AnimationListeners being + * called before the animation is actually complete and support shadows on older + * platforms. + * + * @hide + */ +class CircleImageView extends ImageView { + + private static final int KEY_SHADOW_COLOR = 0x1E000000; + private static final int FILL_SHADOW_COLOR = 0x3D000000; + // PX + private static final float X_OFFSET = 0f; + private static final float Y_OFFSET = 1.75f; + private static final float SHADOW_RADIUS = 3.5f; + private static final int SHADOW_ELEVATION = 4; + + private Animation.AnimationListener mListener; + private int mShadowRadius; + + public CircleImageView(Context context, int color, final float radius) { + super(context); + final float density = getContext().getResources().getDisplayMetrics().density; + final int diameter = (int) (radius * density * 2); + final int shadowYOffset = (int) (density * Y_OFFSET); + final int shadowXOffset = (int) (density * X_OFFSET); + + mShadowRadius = (int) (density * SHADOW_RADIUS); + + ShapeDrawable circle; + if (elevationSupported()) { + circle = new ShapeDrawable(new OvalShape()); + ViewCompat.setElevation(this, SHADOW_ELEVATION * density); + } else { + OvalShape oval = new OvalShadow(mShadowRadius, diameter); + circle = new ShapeDrawable(oval); + ViewCompat.setLayerType(this, ViewCompat.LAYER_TYPE_SOFTWARE, circle.getPaint()); + circle.getPaint().setShadowLayer(mShadowRadius, shadowXOffset, shadowYOffset, + KEY_SHADOW_COLOR); + final int padding = (int) mShadowRadius; + // set padding so the inner image sits correctly within the shadow. + setPadding(padding, padding, padding, padding); + } + circle.getPaint().setColor(color); + setBackgroundDrawable(circle); + } + + private boolean elevationSupported() { + return android.os.Build.VERSION.SDK_INT >= 21; + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + if (!elevationSupported()) { + setMeasuredDimension(getMeasuredWidth() + mShadowRadius*2, getMeasuredHeight() + + mShadowRadius*2); + } + } + + public void setAnimationListener(Animation.AnimationListener listener) { + mListener = listener; + } + + @Override + public void onAnimationStart() { + super.onAnimationStart(); + if (mListener != null) { + mListener.onAnimationStart(getAnimation()); + } + } + + @Override + public void onAnimationEnd() { + super.onAnimationEnd(); + if (mListener != null) { + mListener.onAnimationEnd(getAnimation()); + } + } + + /** + * Update the background color of the circle image view. + */ + public void setBackgroundColor(int colorRes) { + if (getBackground() instanceof ShapeDrawable) { + final Resources res = getResources(); + ((ShapeDrawable) getBackground()).getPaint().setColor(res.getColor(colorRes)); + } + } + + private class OvalShadow extends OvalShape { + private RadialGradient mRadialGradient; + private int mShadowRadius; + private Paint mShadowPaint; + private int mCircleDiameter; + + public OvalShadow(int shadowRadius, int circleDiameter) { + super(); + mShadowPaint = new Paint(); + mShadowRadius = shadowRadius; + mCircleDiameter = circleDiameter; + mRadialGradient = new RadialGradient(mCircleDiameter / 2, mCircleDiameter / 2, + mShadowRadius, new int[] { + FILL_SHADOW_COLOR, Color.TRANSPARENT + }, null, Shader.TileMode.CLAMP); + mShadowPaint.setShader(mRadialGradient); + } + + @Override + public void draw(Canvas canvas, Paint paint) { + final int viewWidth = CircleImageView.this.getWidth(); + final int viewHeight = CircleImageView.this.getHeight(); + canvas.drawCircle(viewWidth / 2, viewHeight / 2, (mCircleDiameter / 2 + mShadowRadius), + mShadowPaint); + canvas.drawCircle(viewWidth / 2, viewHeight / 2, (mCircleDiameter / 2), paint); + } + } +} diff --git a/app/src/main/java/com/hippo/widget/refreshlayout/MaterialProgressDrawable.java b/app/src/main/java/com/hippo/widget/refreshlayout/MaterialProgressDrawable.java new file mode 100644 index 000000000..bd0d01cf6 --- /dev/null +++ b/app/src/main/java/com/hippo/widget/refreshlayout/MaterialProgressDrawable.java @@ -0,0 +1,714 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.widget.refreshlayout; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.ColorFilter; +import android.graphics.Paint; +import android.graphics.Paint.Style; +import android.graphics.Path; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.drawable.Animatable; +import android.graphics.drawable.Drawable; +import android.support.annotation.IntDef; +import android.support.annotation.NonNull; +import android.util.DisplayMetrics; +import android.view.View; +import android.view.animation.AccelerateDecelerateInterpolator; +import android.view.animation.Animation; +import android.view.animation.Interpolator; +import android.view.animation.LinearInterpolator; +import android.view.animation.Transformation; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; + +/** + * Fancy progress indicator for Material theme. + * + * @hide + */ +class MaterialProgressDrawable extends Drawable implements Animatable { + private static final Interpolator LINEAR_INTERPOLATOR = new LinearInterpolator(); + private static final Interpolator END_CURVE_INTERPOLATOR = new EndCurveInterpolator(); + private static final Interpolator START_CURVE_INTERPOLATOR = new StartCurveInterpolator(); + private static final Interpolator EASE_INTERPOLATOR = new AccelerateDecelerateInterpolator(); + + @Retention(RetentionPolicy.CLASS) + @IntDef({LARGE, DEFAULT}) + public @interface ProgressDrawableSize {} + // Maps to ProgressBar.Large style + static final int LARGE = 0; + // Maps to ProgressBar default style + static final int DEFAULT = 1; + + // Maps to ProgressBar default style + private static final int CIRCLE_DIAMETER = 40; + private static final float CENTER_RADIUS = 8.75f; //should add up to 10 when + stroke_width + private static final float STROKE_WIDTH = 2.5f; + + // Maps to ProgressBar.Large style + private static final int CIRCLE_DIAMETER_LARGE = 56; + private static final float CENTER_RADIUS_LARGE = 12.5f; + private static final float STROKE_WIDTH_LARGE = 3f; + + private final int[] COLORS = new int[] { + Color.BLACK + }; + + /** The duration of a single progress spin in milliseconds. */ + private static final int ANIMATION_DURATION = 1000 * 80 / 60; + + /** The number of points in the progress "star". */ + private static final float NUM_POINTS = 5f; + /** The list of animators operating on this drawable. */ + private final ArrayList mAnimators = new ArrayList(); + + /** The indicator ring, used to manage animation state. */ + private final Ring mRing; + + /** Canvas rotation in degrees. */ + private float mRotation; + + /** Layout info for the arrowhead in dp */ + private static final int ARROW_WIDTH = 10; + private static final int ARROW_HEIGHT = 5; + private static final float ARROW_OFFSET_ANGLE = 5; + + /** Layout info for the arrowhead for the large spinner in dp */ + private static final int ARROW_WIDTH_LARGE = 12; + private static final int ARROW_HEIGHT_LARGE = 6; + private static final float MAX_PROGRESS_ARC = .8f; + + private Resources mResources; + private View mParent; + private Animation mAnimation; + private float mRotationCount; + private double mWidth; + private double mHeight; + boolean mFinishing; + + public MaterialProgressDrawable(Context context, View parent) { + mParent = parent; + mResources = context.getResources(); + + mRing = new Ring(mCallback); + mRing.setColors(COLORS); + + updateSizes(DEFAULT); + setupAnimators(); + } + + private void setSizeParameters(double progressCircleWidth, double progressCircleHeight, + double centerRadius, double strokeWidth, float arrowWidth, float arrowHeight) { + final Ring ring = mRing; + final DisplayMetrics metrics = mResources.getDisplayMetrics(); + final float screenDensity = metrics.density; + + mWidth = progressCircleWidth * screenDensity; + mHeight = progressCircleHeight * screenDensity; + ring.setStrokeWidth((float) strokeWidth * screenDensity); + ring.setCenterRadius(centerRadius * screenDensity); + ring.setColorIndex(0); + ring.setArrowDimensions(arrowWidth * screenDensity, arrowHeight * screenDensity); + ring.setInsets((int) mWidth, (int) mHeight); + } + + /** + * Set the overall size for the progress spinner. This updates the radius + * and stroke width of the ring. + * + * @param size One of {@link #LARGE} or + * {@link #DEFAULT} + */ + public void updateSizes(@ProgressDrawableSize int size) { + if (size == LARGE) { + setSizeParameters(CIRCLE_DIAMETER_LARGE, CIRCLE_DIAMETER_LARGE, CENTER_RADIUS_LARGE, + STROKE_WIDTH_LARGE, ARROW_WIDTH_LARGE, ARROW_HEIGHT_LARGE); + } else { + setSizeParameters(CIRCLE_DIAMETER, CIRCLE_DIAMETER, CENTER_RADIUS, STROKE_WIDTH, + ARROW_WIDTH, ARROW_HEIGHT); + } + } + + /** + * @param show Set to true to display the arrowhead on the progress spinner. + */ + public void showArrow(boolean show) { + mRing.setShowArrow(show); + } + + /** + * @param scale Set the scale of the arrowhead for the spinner. + */ + public void setArrowScale(float scale) { + mRing.setArrowScale(scale); + } + + /** + * Set the start and end trim for the progress spinner arc. + * + * @param startAngle start angle + * @param endAngle end angle + */ + public void setStartEndTrim(float startAngle, float endAngle) { + mRing.setStartTrim(startAngle); + mRing.setEndTrim(endAngle); + } + + /** + * Set the amount of rotation to apply to the progress spinner. + * + * @param rotation Rotation is from [0..1] + */ + public void setProgressRotation(float rotation) { + mRing.setRotation(rotation); + } + + /** + * Update the background color of the circle image view. + */ + public void setBackgroundColor(int color) { + mRing.setBackgroundColor(color); + } + + /** + * Set the colors used in the progress animation from color resources. + * The first color will also be the color of the bar that grows in response + * to a user swipe gesture. + * + * @param colors + */ + public void setColorSchemeColors(int... colors) { + mRing.setColors(colors); + mRing.setColorIndex(0); + } + + @Override + public int getIntrinsicHeight() { + return (int) mHeight; + } + + @Override + public int getIntrinsicWidth() { + return (int) mWidth; + } + + @Override + public void draw(Canvas c) { + final Rect bounds = getBounds(); + final int saveCount = c.save(); + c.rotate(mRotation, bounds.exactCenterX(), bounds.exactCenterY()); + mRing.draw(c, bounds); + c.restoreToCount(saveCount); + } + + @Override + public void setAlpha(int alpha) { + mRing.setAlpha(alpha); + } + + public int getAlpha() { + return mRing.getAlpha(); + } + + @Override + public void setColorFilter(ColorFilter colorFilter) { + mRing.setColorFilter(colorFilter); + } + + @SuppressWarnings("unused") + void setRotation(float rotation) { + mRotation = rotation; + invalidateSelf(); + } + + @SuppressWarnings("unused") + private float getRotation() { + return mRotation; + } + + @Override + public int getOpacity() { + return PixelFormat.TRANSLUCENT; + } + + @Override + public boolean isRunning() { + final ArrayList animators = mAnimators; + final int N = animators.size(); + for (int i = 0; i < N; i++) { + final Animation animator = animators.get(i); + if (animator.hasStarted() && !animator.hasEnded()) { + return true; + } + } + return false; + } + + @Override + public void start() { + mAnimation.reset(); + mRing.storeOriginals(); + // Already showing some part of the ring + if (mRing.getEndTrim() != mRing.getStartTrim()) { + mFinishing = true; + mAnimation.setDuration(ANIMATION_DURATION/2); + mParent.startAnimation(mAnimation); + } else { + mRing.setColorIndex(0); + mRing.resetOriginals(); + mAnimation.setDuration(ANIMATION_DURATION); + mParent.startAnimation(mAnimation); + } + } + + @Override + public void stop() { + mParent.clearAnimation(); + setRotation(0); + mRing.setShowArrow(false); + mRing.setColorIndex(0); + mRing.resetOriginals(); + } + + private void applyFinishTranslation(float interpolatedTime, Ring ring) { + // shrink back down and complete a full rotation before + // starting other circles + // Rotation goes between [0..1]. + float targetRotation = (float) (Math.floor(ring.getStartingRotation() / MAX_PROGRESS_ARC) + + 1f); + final float startTrim = ring.getStartingStartTrim() + + (ring.getStartingEndTrim() - ring.getStartingStartTrim()) * interpolatedTime; + ring.setStartTrim(startTrim); + final float rotation = ring.getStartingRotation() + + ((targetRotation - ring.getStartingRotation()) * interpolatedTime); + ring.setRotation(rotation); + } + + private void setupAnimators() { + final Ring ring = mRing; + final Animation animation = new Animation() { + @Override + public void applyTransformation(float interpolatedTime, Transformation t) { + if (mFinishing) { + applyFinishTranslation(interpolatedTime, ring); + } else { + // The minProgressArc is calculated from 0 to create an + // angle that + // matches the stroke width. + final float minProgressArc = (float) Math.toRadians( + ring.getStrokeWidth() / (2 * Math.PI * ring.getCenterRadius())); + final float startingEndTrim = ring.getStartingEndTrim(); + final float startingTrim = ring.getStartingStartTrim(); + final float startingRotation = ring.getStartingRotation(); + + // Offset the minProgressArc to where the endTrim is + // located. + final float minArc = MAX_PROGRESS_ARC - minProgressArc; + final float endTrim = startingEndTrim + (minArc + * START_CURVE_INTERPOLATOR.getInterpolation(interpolatedTime)); + ring.setEndTrim(endTrim); + + final float startTrim = startingTrim + (MAX_PROGRESS_ARC + * END_CURVE_INTERPOLATOR.getInterpolation(interpolatedTime)); + ring.setStartTrim(startTrim); + + final float rotation = startingRotation + (0.25f * interpolatedTime); + ring.setRotation(rotation); + + float groupRotation = ((720.0f / NUM_POINTS) * interpolatedTime) + + (720.0f * (mRotationCount / NUM_POINTS)); + setRotation(groupRotation); + } + } + }; + animation.setRepeatCount(Animation.INFINITE); + animation.setRepeatMode(Animation.RESTART); + animation.setInterpolator(LINEAR_INTERPOLATOR); + animation.setAnimationListener(new Animation.AnimationListener() { + + @Override + public void onAnimationStart(Animation animation) { + mRotationCount = 0; + } + + @Override + public void onAnimationEnd(Animation animation) { + // do nothing + } + + @Override + public void onAnimationRepeat(Animation animation) { + ring.storeOriginals(); + ring.goToNextColor(); + ring.setStartTrim(ring.getEndTrim()); + if (mFinishing) { + // finished closing the last ring from the swipe gesture; go + // into progress mode + mFinishing = false; + animation.setDuration(ANIMATION_DURATION); + ring.setShowArrow(false); + } else { + mRotationCount = (mRotationCount + 1) % (NUM_POINTS); + } + } + }); + mAnimation = animation; + } + + private final Callback mCallback = new Callback() { + @Override + public void invalidateDrawable(Drawable d) { + invalidateSelf(); + } + + @Override + public void scheduleDrawable(Drawable d, Runnable what, long when) { + scheduleSelf(what, when); + } + + @Override + public void unscheduleDrawable(Drawable d, Runnable what) { + unscheduleSelf(what); + } + }; + + private static class Ring { + private final RectF mTempBounds = new RectF(); + private final Paint mPaint = new Paint(); + private final Paint mArrowPaint = new Paint(); + + private final Callback mCallback; + + private float mStartTrim = 0.0f; + private float mEndTrim = 0.0f; + private float mRotation = 0.0f; + private float mStrokeWidth = 5.0f; + private float mStrokeInset = 2.5f; + + private int[] mColors; + // mColorIndex represents the offset into the available mColors that the + // progress circle should currently display. As the progress circle is + // animating, the mColorIndex moves by one to the next available color. + private int mColorIndex; + private float mStartingStartTrim; + private float mStartingEndTrim; + private float mStartingRotation; + private boolean mShowArrow; + private Path mArrow; + private float mArrowScale; + private double mRingCenterRadius; + private int mArrowWidth; + private int mArrowHeight; + private int mAlpha; + private final Paint mCirclePaint = new Paint(); + private int mBackgroundColor; + + public Ring(Callback callback) { + mCallback = callback; + + mPaint.setStrokeCap(Paint.Cap.SQUARE); + mPaint.setAntiAlias(true); + mPaint.setStyle(Style.STROKE); + + mArrowPaint.setStyle(Style.FILL); + mArrowPaint.setAntiAlias(true); + } + + public void setBackgroundColor(int color) { + mBackgroundColor = color; + } + + /** + * Set the dimensions of the arrowhead. + * + * @param width Width of the hypotenuse of the arrow head + * @param height Height of the arrow point + */ + public void setArrowDimensions(float width, float height) { + mArrowWidth = (int) width; + mArrowHeight = (int) height; + } + + /** + * Draw the progress spinner + */ + public void draw(Canvas c, Rect bounds) { + final RectF arcBounds = mTempBounds; + arcBounds.set(bounds); + arcBounds.inset(mStrokeInset, mStrokeInset); + + final float startAngle = (mStartTrim + mRotation) * 360; + final float endAngle = (mEndTrim + mRotation) * 360; + float sweepAngle = endAngle - startAngle; + + mPaint.setColor(mColors[mColorIndex]); + c.drawArc(arcBounds, startAngle, sweepAngle, false, mPaint); + + drawTriangle(c, startAngle, sweepAngle, bounds); + + if (mAlpha < 255) { + mCirclePaint.setColor(mBackgroundColor); + mCirclePaint.setAlpha(255 - mAlpha); + c.drawCircle(bounds.exactCenterX(), bounds.exactCenterY(), bounds.width() / 2, + mCirclePaint); + } + } + + private void drawTriangle(Canvas c, float startAngle, float sweepAngle, Rect bounds) { + if (mShowArrow) { + if (mArrow == null) { + mArrow = new Path(); + mArrow.setFillType(Path.FillType.EVEN_ODD); + } else { + mArrow.reset(); + } + + // Adjust the position of the triangle so that it is inset as + // much as the arc, but also centered on the arc. + float inset = (int) mStrokeInset / 2 * mArrowScale; + float x = (float) (mRingCenterRadius * Math.cos(0) + bounds.exactCenterX()); + float y = (float) (mRingCenterRadius * Math.sin(0) + bounds.exactCenterY()); + + // Update the path each time. This works around an issue in SKIA + // where concatenating a rotation matrix to a scale matrix + // ignored a starting negative rotation. This appears to have + // been fixed as of API 21. + mArrow.moveTo(0, 0); + mArrow.lineTo(mArrowWidth * mArrowScale, 0); + mArrow.lineTo((mArrowWidth * mArrowScale / 2), (mArrowHeight + * mArrowScale)); + mArrow.offset(x - inset, y); + mArrow.close(); + // draw a triangle + mArrowPaint.setColor(mColors[mColorIndex]); + c.rotate(startAngle + sweepAngle - ARROW_OFFSET_ANGLE, bounds.exactCenterX(), + bounds.exactCenterY()); + c.drawPath(mArrow, mArrowPaint); + } + } + + /** + * Set the colors the progress spinner alternates between. + * + * @param colors Array of integers describing the colors. Must be non-null. + */ + public void setColors(@NonNull int[] colors) { + mColors = colors; + // if colors are reset, make sure to reset the color index as well + setColorIndex(0); + } + + /** + * @param index Index into the color array of the color to display in + * the progress spinner. + */ + public void setColorIndex(int index) { + mColorIndex = index; + } + + /** + * Proceed to the next available ring color. This will automatically + * wrap back to the beginning of colors. + */ + public void goToNextColor() { + mColorIndex = (mColorIndex + 1) % (mColors.length); + } + + public void setColorFilter(ColorFilter filter) { + mPaint.setColorFilter(filter); + invalidateSelf(); + } + + /** + * @param alpha Set the alpha of the progress spinner and associated arrowhead. + */ + public void setAlpha(int alpha) { + mAlpha = alpha; + } + + /** + * @return Current alpha of the progress spinner and arrowhead. + */ + public int getAlpha() { + return mAlpha; + } + + /** + * @param strokeWidth Set the stroke width of the progress spinner in pixels. + */ + public void setStrokeWidth(float strokeWidth) { + mStrokeWidth = strokeWidth; + mPaint.setStrokeWidth(strokeWidth); + invalidateSelf(); + } + + @SuppressWarnings("unused") + public float getStrokeWidth() { + return mStrokeWidth; + } + + @SuppressWarnings("unused") + public void setStartTrim(float startTrim) { + mStartTrim = startTrim; + invalidateSelf(); + } + + @SuppressWarnings("unused") + public float getStartTrim() { + return mStartTrim; + } + + public float getStartingStartTrim() { + return mStartingStartTrim; + } + + public float getStartingEndTrim() { + return mStartingEndTrim; + } + + @SuppressWarnings("unused") + public void setEndTrim(float endTrim) { + mEndTrim = endTrim; + invalidateSelf(); + } + + @SuppressWarnings("unused") + public float getEndTrim() { + return mEndTrim; + } + + @SuppressWarnings("unused") + public void setRotation(float rotation) { + mRotation = rotation; + invalidateSelf(); + } + + @SuppressWarnings("unused") + public float getRotation() { + return mRotation; + } + + public void setInsets(int width, int height) { + final float minEdge = (float) Math.min(width, height); + float insets; + if (mRingCenterRadius <= 0 || minEdge < 0) { + insets = (float) Math.ceil(mStrokeWidth / 2.0f); + } else { + insets = (float) (minEdge / 2.0f - mRingCenterRadius); + } + mStrokeInset = insets; + } + + @SuppressWarnings("unused") + public float getInsets() { + return mStrokeInset; + } + + /** + * @param centerRadius Inner radius in px of the circle the progress + * spinner arc traces. + */ + public void setCenterRadius(double centerRadius) { + mRingCenterRadius = centerRadius; + } + + public double getCenterRadius() { + return mRingCenterRadius; + } + + /** + * @param show Set to true to show the arrow head on the progress spinner. + */ + public void setShowArrow(boolean show) { + if (mShowArrow != show) { + mShowArrow = show; + invalidateSelf(); + } + } + + /** + * @param scale Set the scale of the arrowhead for the spinner. + */ + public void setArrowScale(float scale) { + if (scale != mArrowScale) { + mArrowScale = scale; + invalidateSelf(); + } + } + + /** + * @return The amount the progress spinner is currently rotated, between [0..1]. + */ + public float getStartingRotation() { + return mStartingRotation; + } + + /** + * If the start / end trim are offset to begin with, store them so that + * animation starts from that offset. + */ + public void storeOriginals() { + mStartingStartTrim = mStartTrim; + mStartingEndTrim = mEndTrim; + mStartingRotation = mRotation; + } + + /** + * Reset the progress spinner to default rotation, start and end angles. + */ + public void resetOriginals() { + mStartingStartTrim = 0; + mStartingEndTrim = 0; + mStartingRotation = 0; + setStartTrim(0); + setEndTrim(0); + setRotation(0); + } + + private void invalidateSelf() { + mCallback.invalidateDrawable(null); + } + } + + /** + * Squishes the interpolation curve into the second half of the animation. + */ + private static class EndCurveInterpolator extends AccelerateDecelerateInterpolator { + @Override + public float getInterpolation(float input) { + return super.getInterpolation(Math.max(0, (input - 0.5f) * 2.0f)); + } + } + + /** + * Squishes the interpolation curve into the first half of the animation. + */ + private static class StartCurveInterpolator extends AccelerateDecelerateInterpolator { + @Override + public float getInterpolation(float input) { + return super.getInterpolation(Math.min(1, input * 2.0f)); + } + } +} diff --git a/app/src/main/java/com/hippo/widget/refreshlayout/RefreshLayout.java b/app/src/main/java/com/hippo/widget/refreshlayout/RefreshLayout.java new file mode 100644 index 000000000..54129e748 --- /dev/null +++ b/app/src/main/java/com/hippo/widget/refreshlayout/RefreshLayout.java @@ -0,0 +1,1073 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.widget.refreshlayout; + +import android.content.Context; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.support.v4.view.MotionEventCompat; +import android.support.v4.view.ViewCompat; +import android.util.AttributeSet; +import android.util.DisplayMetrics; +import android.util.Log; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.view.animation.Animation; +import android.view.animation.Animation.AnimationListener; +import android.view.animation.DecelerateInterpolator; +import android.view.animation.Transformation; +import android.widget.AbsListView; + +/** + * The SwipeRefreshLayout should be used whenever the user can refresh the + * contents of a view via a vertical swipe gesture. The activity that + * instantiates this view should add an OnRefreshListener to be notified + * whenever the swipe to refresh gesture is completed. The SwipeRefreshLayout + * will notify the listener each and every time the gesture is completed again; + * the listener is responsible for correctly determining when to actually + * initiate a refresh of its content. If the listener determines there should + * not be a refresh, it must call setRefreshing(false) to cancel any visual + * indication of a refresh. If an activity wishes to show just the progress + * animation, it should call setRefreshing(true). To disable the gesture and + * progress animation, call setEnabled(false) on the view. + *

+ * This layout should be made the parent of the view that will be refreshed as a + * result of the gesture and can only support one direct child. This view will + * also be made the target of the gesture and will be forced to match both the + * width and the height supplied in this layout. The SwipeRefreshLayout does not + * provide accessibility events; instead, a menu item must be provided to allow + * refresh of the content wherever this gesture is used. + *

+ */ +public class RefreshLayout extends ViewGroup { + // Maps to ProgressBar.Large style + public static final int LARGE = MaterialProgressDrawable.LARGE; + // Maps to ProgressBar default style + public static final int DEFAULT = MaterialProgressDrawable.DEFAULT; + + private static final String LOG_TAG = RefreshLayout.class.getSimpleName(); + + private static final int MAX_ALPHA = 255; + private static final int STARTING_PROGRESS_ALPHA = (int) (.3f * MAX_ALPHA); + + private static final int CIRCLE_DIAMETER = 40; + private static final int CIRCLE_DIAMETER_LARGE = 56; + + private static final float DECELERATE_INTERPOLATION_FACTOR = 2f; + private static final int INVALID_POINTER = -1; + private static final float DRAG_RATE = .5f; + + // Max amount of circle that can be filled by progress during swipe gesture, + // where 1.0 is a full circle + private static final float MAX_PROGRESS_ANGLE = .8f; + + private static final int SCALE_DOWN_DURATION = 150; + + private static final int ALPHA_ANIMATION_DURATION = 300; + + private static final int ANIMATE_TO_TRIGGER_DURATION = 200; + + private static final int ANIMATE_TO_START_DURATION = 200; + + // Default background for the progress spinner + private static final int CIRCLE_BG_LIGHT = 0xFFFAFAFA; + // Default offset in dips from the top of the view to where the progress spinner should stop + private static final int DEFAULT_CIRCLE_TARGET = 64; + + private static final float PROGRESS_BAR_HEIGHT = 4; + + private View mTarget; // the target of the gesture + private OnHeaderRefreshListener mHeaderListener; + private boolean mHeaderRefreshing = false; + private boolean mHeaderEnable = true; + private int mTouchSlop; + private float mTotalDragDistance = -1; + private int mMediumAnimationDuration; + private int mCurrentTargetOffsetTop; + // Whether or not the starting offset has been determined. + private boolean mOriginalOffsetCalculated = false; + + private float mInitialMotionY; + private boolean mIsBeingDragged; + private int mActivePointerId = INVALID_POINTER; + // Whether this item is scaled up rather than clipped + private boolean mScale; + + private final DecelerateInterpolator mDecelerateInterpolator; + private static final int[] LAYOUT_ATTRS = new int[] { + android.R.attr.enabled + }; + + private CircleImageView mCircleView; + private int mCircleViewIndex = -1; + + protected int mFrom; + + private float mStartingScale; + + protected int mOriginalOffsetTop; + + private MaterialProgressDrawable mProgress; + + private Animation mScaleAnimation; + + private Animation mScaleDownAnimation; + + private Animation mAlphaStartAnimation; + + private Animation mAlphaMaxAnimation; + + private Animation mScaleDownToStartAnimation; + + private float mSpinnerFinalOffset; + + private boolean mNotify; + + private int mCircleWidth; + + private int mCircleHeight; + + // Whether the client has set a custom starting position; + private boolean mUsingCustomStart; + + private SwipeProgressBar mProgressBar; //the thing that shows footer progress is going + private OnFooterRefreshListener mFooterListener; + private boolean mFooterRefreshing = false; + private boolean mFooterEnable = true; + private int mProgressBarHeight; + private float mCurrPercentage = 0; + + private AnimationListener mRefreshListener = new AnimationListener() { + @Override + public void onAnimationStart(Animation animation) { + } + + @Override + public void onAnimationRepeat(Animation animation) { + } + + @Override + public void onAnimationEnd(Animation animation) { + if (mHeaderRefreshing) { + // Make sure the progress view is fully visible + mProgress.setAlpha(MAX_ALPHA); + mProgress.start(); + if (mNotify) { + if (mHeaderListener != null) { + mHeaderListener.onHeaderRefresh(); + } + } + } else { + mProgress.stop(); + mCircleView.setVisibility(View.GONE); + setColorViewAlpha(MAX_ALPHA); + // Return the circle to its start position + if (mScale) { + setAnimationProgress(0 /* animation complete and view is hidden */); + } else { + setTargetOffsetTopAndBottom(mOriginalOffsetTop - mCurrentTargetOffsetTop, + true /* requires update */); + } + } + mCurrentTargetOffsetTop = mCircleView.getTop(); + } + }; + + private void setColorViewAlpha(int targetAlpha) { + mCircleView.getBackground().setAlpha(targetAlpha); + mProgress.setAlpha(targetAlpha); + } + + /** + * The refresh indicator starting and resting position is always positioned + * near the top of the refreshing content. This position is a consistent + * location, but can be adjusted in either direction based on whether or not + * there is a toolbar or actionbar present. + * + * @param scale Set to true if there is no view at a higher z-order than + * where the progress spinner is set to appear. + * @param start The offset in pixels from the top of this view at which the + * progress spinner should appear. + * @param end The offset in pixels from the top of this view at which the + * progress spinner should come to rest after a successful swipe + * gesture. + */ + public void setProgressViewOffset(boolean scale, int start, int end) { + mScale = scale; + mCircleView.setVisibility(View.GONE); + mOriginalOffsetTop = mCurrentTargetOffsetTop = start; + mSpinnerFinalOffset = end; + mUsingCustomStart = true; + mCircleView.invalidate(); + } + + /** + * The refresh indicator resting position is always positioned near the top + * of the refreshing content. This position is a consistent location, but + * can be adjusted in either direction based on whether or not there is a + * toolbar or actionbar present. + * + * @param scale Set to true if there is no view at a higher z-order than + * where the progress spinner is set to appear. + * @param end The offset in pixels from the top of this view at which the + * progress spinner should come to rest after a successful swipe + * gesture. + */ + public void setProgressViewEndTarget(boolean scale, int end) { + mSpinnerFinalOffset = end; + mScale = scale; + mCircleView.invalidate(); + } + + /** + * One of DEFAULT, or LARGE. + */ + public void setSize(int size) { + if (size != MaterialProgressDrawable.LARGE && size != MaterialProgressDrawable.DEFAULT) { + return; + } + final DisplayMetrics metrics = getResources().getDisplayMetrics(); + if (size == MaterialProgressDrawable.LARGE) { + mCircleHeight = mCircleWidth = (int) (CIRCLE_DIAMETER_LARGE * metrics.density); + } else { + mCircleHeight = mCircleWidth = (int) (CIRCLE_DIAMETER * metrics.density); + } + // force the bounds of the progress circle inside the circle view to + // update by setting it to null before updating its size and then + // re-setting it + mCircleView.setImageDrawable(null); + mProgress.updateSizes(size); + mCircleView.setImageDrawable(mProgress); + } + + /** + * Simple constructor to use when creating a SwipeRefreshLayout from code. + * + * @param context + */ + public RefreshLayout(Context context) { + this(context, null); + } + + /** + * Constructor that is called when inflating SwipeRefreshLayout from XML. + * + * @param context + * @param attrs + */ + public RefreshLayout(Context context, AttributeSet attrs) { + super(context, attrs); + + mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); + + mMediumAnimationDuration = getResources().getInteger( + android.R.integer.config_mediumAnimTime); + + setWillNotDraw(false); + mDecelerateInterpolator = new DecelerateInterpolator(DECELERATE_INTERPOLATION_FACTOR); + + final TypedArray a = context.obtainStyledAttributes(attrs, LAYOUT_ATTRS); + setEnabled(a.getBoolean(0, true)); + a.recycle(); + + final DisplayMetrics metrics = getResources().getDisplayMetrics(); + mCircleWidth = (int) (CIRCLE_DIAMETER * metrics.density); + mCircleHeight = (int) (CIRCLE_DIAMETER * metrics.density); + createHeaderProgressView(); + + mProgressBarHeight = (int) (metrics.density * PROGRESS_BAR_HEIGHT); + mProgressBar = new SwipeProgressBar(this); + + ViewCompat.setChildrenDrawingOrderEnabled(this, true); + // the absolute offset has to take into account that the circle starts at an offset + mSpinnerFinalOffset = DEFAULT_CIRCLE_TARGET * metrics.density; + mTotalDragDistance = mSpinnerFinalOffset; + } + + @Override + protected int getChildDrawingOrder(int childCount, int i) { + if (mCircleViewIndex < 0) { + return i; + } else if (i == childCount - 1) { + // Draw the selected child last + return mCircleViewIndex; + } else if (i >= mCircleViewIndex) { + // Move the children after the selected child earlier one + return i + 1; + } else { + // Keep the children before the selected child the same + return i; + } + } + + private void createHeaderProgressView() { + mCircleView = new CircleImageView(getContext(), CIRCLE_BG_LIGHT, CIRCLE_DIAMETER/2); + mProgress = new MaterialProgressDrawable(getContext(), this); + mProgress.setBackgroundColor(CIRCLE_BG_LIGHT); + mCircleView.setImageDrawable(mProgress); + mCircleView.setVisibility(View.GONE); + addView(mCircleView); + } + + /** + * Set the listener to be notified when a refresh is triggered via the swipe + * gesture. + */ + public void setOnHeaderRefreshListener(OnHeaderRefreshListener listener) { + mHeaderListener = listener; + } + + /** + * Set the listener to be notified when it is almost bottom. + */ + public void setOnFooterRefreshListener(OnFooterRefreshListener listener) { + mFooterListener = listener; + } + + /** + * Pre API 11, alpha is used to make the progress circle appear instead of scale. + */ + private boolean isAlphaUsedForScale() { + return android.os.Build.VERSION.SDK_INT < 11; + } + + public void setTriggerPercentage(float percent) { + if (percent == 0f) { + // No-op. A null trigger means it's uninitialized, and setting it to zero-percent + // means we're trying to reset state, so there's nothing to reset in this case. + mCurrPercentage = 0; + return; + } + mCurrPercentage = percent; + mProgressBar.setTriggerPercentage(percent); + } + + /** + * Notify the widget that refresh state has changed. Do not call this when + * refresh is triggered by a swipe gesture. + * + * @param refreshing Whether or not the view should show refresh progress. + */ + public void setHeaderRefreshing(boolean refreshing) { + if (refreshing && mFooterRefreshing) { + return; + } + + if (refreshing && mHeaderRefreshing != refreshing) { + // scale and show + mHeaderRefreshing = refreshing; + int endTarget = 0; + if (!mUsingCustomStart) { + endTarget = (int) (mSpinnerFinalOffset + mOriginalOffsetTop); + } else { + endTarget = (int) mSpinnerFinalOffset; + } + setTargetOffsetTopAndBottom(endTarget - mCurrentTargetOffsetTop, + true /* requires update */); + mNotify = false; + startScaleUpAnimation(mRefreshListener); + } else { + setHeaderRefreshing(refreshing, false /* notify */); + } + } + + public void setFooterRefreshing(boolean refreshing) { + if (refreshing && mHeaderRefreshing) { + return; + } + + if (mFooterRefreshing != refreshing) { + mFooterRefreshing = refreshing; + if (refreshing) { + mProgressBar.start(); + } else { + mProgressBar.stop(); + } + } + } + + private void startScaleUpAnimation(AnimationListener listener) { + mCircleView.setVisibility(View.VISIBLE); + if (android.os.Build.VERSION.SDK_INT >= 11) { + // Pre API 11, alpha is used in place of scale up to show the + // progress circle appearing. + // Don't adjust the alpha during appearance otherwise. + mProgress.setAlpha(MAX_ALPHA); + } + mScaleAnimation = new Animation() { + @Override + public void applyTransformation(float interpolatedTime, Transformation t) { + setAnimationProgress(interpolatedTime); + } + }; + mScaleAnimation.setDuration(mMediumAnimationDuration); + if (listener != null) { + mCircleView.setAnimationListener(listener); + } + mCircleView.clearAnimation(); + mCircleView.startAnimation(mScaleAnimation); + } + + /** + * Pre API 11, this does an alpha animation. + * @param progress + */ + private void setAnimationProgress(float progress) { + if (isAlphaUsedForScale()) { + setColorViewAlpha((int) (progress * MAX_ALPHA)); + } else { + ViewCompat.setScaleX(mCircleView, progress); + ViewCompat.setScaleY(mCircleView, progress); + } + } + + private void setHeaderRefreshing(boolean refreshing, final boolean notify) { + if (mHeaderRefreshing != refreshing) { + mNotify = notify; + ensureTarget(); + mHeaderRefreshing = refreshing; + if (mHeaderRefreshing) { + animateOffsetToCorrectPosition(mCurrentTargetOffsetTop, mRefreshListener); + } else { + startScaleDownAnimation(mRefreshListener); + } + } + } + + private void startScaleDownAnimation(AnimationListener listener) { + mScaleDownAnimation = new Animation() { + @Override + public void applyTransformation(float interpolatedTime, Transformation t) { + setAnimationProgress(1 - interpolatedTime); + } + }; + mScaleDownAnimation.setDuration(SCALE_DOWN_DURATION); + mCircleView.setAnimationListener(listener); + mCircleView.clearAnimation(); + mCircleView.startAnimation(mScaleDownAnimation); + } + + private void startProgressAlphaStartAnimation() { + mAlphaStartAnimation = startAlphaAnimation(mProgress.getAlpha(), STARTING_PROGRESS_ALPHA); + } + + private void startProgressAlphaMaxAnimation() { + mAlphaMaxAnimation = startAlphaAnimation(mProgress.getAlpha(), MAX_ALPHA); + } + + private Animation startAlphaAnimation(final int startingAlpha, final int endingAlpha) { + // Pre API 11, alpha is used in place of scale. Don't also use it to + // show the trigger point. + if (mScale && isAlphaUsedForScale()) { + return null; + } + Animation alpha = new Animation() { + @Override + public void applyTransformation(float interpolatedTime, Transformation t) { + mProgress + .setAlpha((int) (startingAlpha+ ((endingAlpha - startingAlpha) + * interpolatedTime))); + } + }; + alpha.setDuration(ALPHA_ANIMATION_DURATION); + // Clear out the previous animation listeners. + mCircleView.setAnimationListener(null); + mCircleView.clearAnimation(); + mCircleView.startAnimation(alpha); + return alpha; + } + + /** + * Set the background color of the progress spinner disc. + * + * @param colorRes Resource id of the color. + */ + public void setProgressBackgroundColor(int colorRes) { + mCircleView.setBackgroundColor(colorRes); + mProgress.setBackgroundColor(getResources().getColor(colorRes)); + } + + /** + * @deprecated Use {@link #setHeaderColorSchemeResources(int...)} + */ + @Deprecated + public void setHeaderColorScheme(int... colors) { + setHeaderColorSchemeResources(colors); + } + + /** + * Set the color resources used in the progress animation from color resources. + * The first color will also be the color of the bar that grows in response + * to a user swipe gesture. + * + * @param colorResIds + */ + public void setHeaderColorSchemeResources(int... colorResIds) { + final Resources res = getResources(); + int[] colorRes = new int[colorResIds.length]; + for (int i = 0; i < colorResIds.length; i++) { + colorRes[i] = res.getColor(colorResIds[i]); + } + setHeaderColorSchemeColors(colorRes); + } + + /** + * Set the colors used in the progress animation. The first + * color will also be the color of the bar that grows in response to a user + * swipe gesture. + * + * @param colors + */ + public void setHeaderColorSchemeColors(int... colors) { + ensureTarget(); + mProgress.setColorSchemeColors(colors); + } + + /** + * @deprecated Use {@link #setFooterColorSchemeResources(int, int, int, int)} + */ + @Deprecated + public void setFooterColorScheme(int colorRes1, int colorRes2, int colorRes3, int colorRes4) { + setFooterColorSchemeResources(colorRes1, colorRes2, colorRes3, colorRes4); + } + + /** + * Set the four colors used in the progress animation from color resources. + * The first color will also be the color of the bar that grows in response + * to a user swipe gesture. + */ + public void setFooterColorSchemeResources(int colorRes1, int colorRes2, int colorRes3, + int colorRes4) { + final Resources res = getResources(); + setFooterColorSchemeColors(res.getColor(colorRes1), res.getColor(colorRes2), + res.getColor(colorRes3), res.getColor(colorRes4)); + } + + /** + * Set the four colors used in the progress animation. The first color will + * also be the color of the bar that grows in response to a user swipe + * gesture. + */ + public void setFooterColorSchemeColors(int color1, int color2, int color3, int color4) { + ensureTarget(); + mProgressBar.setColorScheme(color1, color2, color3, color4); + } + + public void setHeaderEnable(boolean headerEnable) { + mHeaderEnable = headerEnable; + if (headerEnable == false && mHeaderRefreshing == true) { + setHeaderRefreshing(false); + } + } + + public void setFooterEnable(boolean footerEnable) { + mFooterEnable = footerEnable; + if (footerEnable == false && mFooterRefreshing == true) { + setFooterRefreshing(false); + } + } + + /** + * @return isHeaderRefreshing() | isFooterRefreshing() + */ + public boolean isRefreshing() { + return mHeaderRefreshing | mFooterRefreshing; + } + + /** + * @return whether RefreshLayout is actively showing refresh progress. + */ + public boolean isHeaderRefreshing() { + return mHeaderRefreshing; + } + + /** + * @return whether footer job is actively doing + */ + public boolean isFooterRefreshing() { + return mFooterRefreshing; + } + + private void ensureTarget() { + // Don't bother getting the parent height if the parent hasn't been laid + // out yet. + if (mTarget == null) { + for (int i = 0; i < getChildCount(); i++) { + View child = getChildAt(i); + if (!child.equals(mCircleView)) { + mTarget = child; + break; + } + } + } + } + + /** + * Set the distance to trigger a sync in dips + * + * @param distance + */ + public void setDistanceToTriggerSync(int distance) { + mTotalDragDistance = distance; + } + + @Override + public void draw(Canvas canvas) { + super.draw(canvas); + mProgressBar.draw(canvas); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + final int width = getMeasuredWidth(); + final int height = getMeasuredHeight(); + if (getChildCount() == 0) { + return; + } + if (mTarget == null) { + ensureTarget(); + } + if (mTarget == null) { + return; + } + final View child = mTarget; + final int childLeft = getPaddingLeft(); + final int childTop = getPaddingTop(); + final int childWidth = width - getPaddingLeft() - getPaddingRight(); + final int childHeight = height - getPaddingTop() - getPaddingBottom(); + child.layout(childLeft, childTop, childLeft + childWidth, childTop + childHeight); + int circleWidth = mCircleView.getMeasuredWidth(); + int circleHeight = mCircleView.getMeasuredHeight(); + mCircleView.layout((width / 2 - circleWidth / 2), mCurrentTargetOffsetTop, + (width / 2 + circleWidth / 2), mCurrentTargetOffsetTop + circleHeight); + + mProgressBar.setBounds(0, height - mProgressBarHeight, width, height); + } + + @Override + public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + if (mTarget == null) { + ensureTarget(); + } + if (mTarget == null) { + return; + } + mTarget.measure(MeasureSpec.makeMeasureSpec( + getMeasuredWidth() - getPaddingLeft() - getPaddingRight(), + MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec( + getMeasuredHeight() - getPaddingTop() - getPaddingBottom(), MeasureSpec.EXACTLY)); + mCircleView.measure(MeasureSpec.makeMeasureSpec(mCircleWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(mCircleHeight, MeasureSpec.EXACTLY)); + if (!mUsingCustomStart && !mOriginalOffsetCalculated) { + mOriginalOffsetCalculated = true; + mCurrentTargetOffsetTop = mOriginalOffsetTop = -mCircleView.getMeasuredHeight(); + } + mCircleViewIndex = -1; + // Get the index of the circleview. + for (int index = 0; index < getChildCount(); index++) { + if (getChildAt(index) == mCircleView) { + mCircleViewIndex = index; + break; + } + } + } + + /** + * @return Whether it is possible for the child view of this layout to + * scroll up. Override this if the child view is a custom view. + */ + public boolean canChildScrollUp() { + if (android.os.Build.VERSION.SDK_INT < 14) { + if (mTarget instanceof AbsListView) { + final AbsListView absListView = (AbsListView) mTarget; + return absListView.getChildCount() > 0 + && (absListView.getFirstVisiblePosition() > 0 || absListView.getChildAt(0) + .getTop() < absListView.getPaddingTop()); + } else { + return mTarget.getScrollY() > 0; + } + } else { + return ViewCompat.canScrollVertically(mTarget, -1); + } + } + + /** + * @return {@code true} if child view almost scroll to bottom. + */ + public boolean isAlmostBottom() { + if (mTarget instanceof AbsListView) { + final AbsListView absListView = (AbsListView) mTarget; + return absListView.getLastVisiblePosition() > absListView.getCount() - 2; + } else { + if (android.os.Build.VERSION.SDK_INT >= 14) { + return !ViewCompat.canScrollVertically(mTarget, getHeight() / 4); + } else { + return true; + } + } + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + ensureTarget(); + + final int action = MotionEventCompat.getActionMasked(ev); + + if (!isEnabled() || mHeaderRefreshing || mFooterRefreshing) { + return false; + } + + if (mFooterEnable && isAlmostBottom()) { + // Check footer + if (mFooterListener == null || mFooterListener.onFooterRefresh()) { + setFooterRefreshing(true); + return false; + } + } + + if (!mHeaderEnable || canChildScrollUp() || mFooterRefreshing) { + // Fail fast if we're not in a state where a swipe is possible + return false; + } + + switch (action) { + case MotionEvent.ACTION_DOWN: + setTargetOffsetTopAndBottom(mOriginalOffsetTop - mCircleView.getTop(), true); + mActivePointerId = MotionEventCompat.getPointerId(ev, 0); + mIsBeingDragged = false; + final float initialMotionY = getMotionEventY(ev, mActivePointerId); + if (initialMotionY == -1) { + return false; + } + mInitialMotionY = initialMotionY; + + case MotionEvent.ACTION_MOVE: + if (mActivePointerId == INVALID_POINTER) { + Log.e(LOG_TAG, "Got ACTION_MOVE event but don't have an active pointer id."); + return false; + } + + final float y = getMotionEventY(ev, mActivePointerId); + if (y == -1) { + return false; + } + final float yDiff = y - mInitialMotionY; + if (yDiff > mTouchSlop && !mIsBeingDragged) { + mIsBeingDragged = true; + mProgress.setAlpha(STARTING_PROGRESS_ALPHA); + } + break; + + case MotionEventCompat.ACTION_POINTER_UP: + onSecondaryPointerUp(ev); + break; + + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + mIsBeingDragged = false; + mActivePointerId = INVALID_POINTER; + break; + } + + return mIsBeingDragged; + } + + private float getMotionEventY(MotionEvent ev, int activePointerId) { + final int index = MotionEventCompat.findPointerIndex(ev, activePointerId); + if (index < 0) { + return -1; + } + return MotionEventCompat.getY(ev, index); + } + + @Override + public void requestDisallowInterceptTouchEvent(boolean b) { + // Nope. + } + + private boolean isAnimationRunning(Animation animation) { + return animation != null && animation.hasStarted() && !animation.hasEnded(); + } + + @Override + public boolean onTouchEvent(MotionEvent ev) { + final int action = MotionEventCompat.getActionMasked(ev); + + if (!isEnabled() || mHeaderRefreshing || mFooterRefreshing) { + return false; + } + + if (mFooterEnable && isAlmostBottom()) { + // Check footer + if (mFooterListener == null || mFooterListener.onFooterRefresh()) { + setFooterRefreshing(true); + return false; + } + } + + if (!mHeaderEnable || canChildScrollUp() || mFooterRefreshing) { + // Fail fast if we're not in a state where a swipe is possible + return false; + } + + switch (action) { + case MotionEvent.ACTION_DOWN: + mActivePointerId = MotionEventCompat.getPointerId(ev, 0); + mIsBeingDragged = false; + break; + + case MotionEvent.ACTION_MOVE: { + final int pointerIndex = MotionEventCompat.findPointerIndex(ev, mActivePointerId); + if (pointerIndex < 0) { + Log.e(LOG_TAG, "Got ACTION_MOVE event but have an invalid active pointer id."); + return false; + } + + final float y = MotionEventCompat.getY(ev, pointerIndex); + final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE; + if (mIsBeingDragged) { + mProgress.showArrow(true); + float originalDragPercent = overscrollTop / mTotalDragDistance; + if (originalDragPercent < 0) { + return false; + } + float dragPercent = Math.min(1f, Math.abs(originalDragPercent)); + float adjustedPercent = (float) Math.max(dragPercent - .4, 0) * 5 / 3; + float extraOS = Math.abs(overscrollTop) - mTotalDragDistance; + float slingshotDist = mUsingCustomStart ? mSpinnerFinalOffset + - mOriginalOffsetTop : mSpinnerFinalOffset; + float tensionSlingshotPercent = Math.max(0, + Math.min(extraOS, slingshotDist * 2) / slingshotDist); + float tensionPercent = (float) ((tensionSlingshotPercent / 4) - Math.pow( + (tensionSlingshotPercent / 4), 2)) * 2f; + float extraMove = (slingshotDist) * tensionPercent * 2; + + int targetY = mOriginalOffsetTop + + (int) ((slingshotDist * dragPercent) + extraMove); + // where 1.0f is a full circle + if (mCircleView.getVisibility() != View.VISIBLE) { + mCircleView.setVisibility(View.VISIBLE); + } + if (!mScale) { + ViewCompat.setScaleX(mCircleView, 1f); + ViewCompat.setScaleY(mCircleView, 1f); + } + if (overscrollTop < mTotalDragDistance) { + if (mScale) { + setAnimationProgress(overscrollTop / mTotalDragDistance); + } + if (mProgress.getAlpha() > STARTING_PROGRESS_ALPHA + && !isAnimationRunning(mAlphaStartAnimation)) { + // Animate the alpha + startProgressAlphaStartAnimation(); + } + float strokeStart = (float) (adjustedPercent * .8f); + mProgress.setStartEndTrim(0f, Math.min(MAX_PROGRESS_ANGLE, strokeStart)); + mProgress.setArrowScale(Math.min(1f, adjustedPercent)); + } else { + if (mProgress.getAlpha() < MAX_ALPHA + && !isAnimationRunning(mAlphaMaxAnimation)) { + // Animate the alpha + startProgressAlphaMaxAnimation(); + } + } + float rotation = (-0.25f + .4f * adjustedPercent + tensionPercent * 2) * .5f; + mProgress.setProgressRotation(rotation); + setTargetOffsetTopAndBottom(targetY - mCurrentTargetOffsetTop, + true /* requires update */); + } + break; + } + case MotionEventCompat.ACTION_POINTER_DOWN: { + final int index = MotionEventCompat.getActionIndex(ev); + mActivePointerId = MotionEventCompat.getPointerId(ev, index); + break; + } + + case MotionEventCompat.ACTION_POINTER_UP: + onSecondaryPointerUp(ev); + break; + + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: { + if (mActivePointerId == INVALID_POINTER) { + if (action == MotionEvent.ACTION_UP) { + Log.e(LOG_TAG, "Got ACTION_UP event but don't have an active pointer id."); + } + return false; + } + final int pointerIndex = MotionEventCompat.findPointerIndex(ev, mActivePointerId); + final float y = MotionEventCompat.getY(ev, pointerIndex); + final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE; + mIsBeingDragged = false; + if (overscrollTop > mTotalDragDistance) { + setHeaderRefreshing(true, true /* notify */); + } else { + // cancel refresh + mHeaderRefreshing = false; + mProgress.setStartEndTrim(0f, 0f); + AnimationListener listener = null; + if (!mScale) { + listener = new AnimationListener() { + + @Override + public void onAnimationStart(Animation animation) { + } + + @Override + public void onAnimationEnd(Animation animation) { + if (!mScale) { + startScaleDownAnimation(null); + } + } + + @Override + public void onAnimationRepeat(Animation animation) { + } + + }; + } + animateOffsetToStartPosition(mCurrentTargetOffsetTop, listener); + mProgress.showArrow(false); + } + mActivePointerId = INVALID_POINTER; + return false; + } + } + + return true; + } + + private void animateOffsetToCorrectPosition(int from, AnimationListener listener) { + mFrom = from; + mAnimateToCorrectPosition.reset(); + mAnimateToCorrectPosition.setDuration(ANIMATE_TO_TRIGGER_DURATION); + mAnimateToCorrectPosition.setInterpolator(mDecelerateInterpolator); + if (listener != null) { + mCircleView.setAnimationListener(listener); + } + mCircleView.clearAnimation(); + mCircleView.startAnimation(mAnimateToCorrectPosition); + } + + private void animateOffsetToStartPosition(int from, AnimationListener listener) { + if (mScale) { + // Scale the item back down + startScaleDownReturnToStartAnimation(from, listener); + } else { + mFrom = from; + mAnimateToStartPosition.reset(); + mAnimateToStartPosition.setDuration(ANIMATE_TO_START_DURATION); + mAnimateToStartPosition.setInterpolator(mDecelerateInterpolator); + if (listener != null) { + mCircleView.setAnimationListener(listener); + } + mCircleView.clearAnimation(); + mCircleView.startAnimation(mAnimateToStartPosition); + } + } + + private final Animation mAnimateToCorrectPosition = new Animation() { + @Override + public void applyTransformation(float interpolatedTime, Transformation t) { + int targetTop = 0; + int endTarget = 0; + if (!mUsingCustomStart) { + endTarget = (int) (mSpinnerFinalOffset - Math.abs(mOriginalOffsetTop)); + } else { + endTarget = (int) mSpinnerFinalOffset; + } + targetTop = (mFrom + (int) ((endTarget - mFrom) * interpolatedTime)); + int offset = targetTop - mCircleView.getTop(); + setTargetOffsetTopAndBottom(offset, false /* requires update */); + } + }; + + private void moveToStart(float interpolatedTime) { + int targetTop = 0; + targetTop = (mFrom + (int) ((mOriginalOffsetTop - mFrom) * interpolatedTime)); + int offset = targetTop - mCircleView.getTop(); + setTargetOffsetTopAndBottom(offset, false /* requires update */); + } + + private final Animation mAnimateToStartPosition = new Animation() { + @Override + public void applyTransformation(float interpolatedTime, Transformation t) { + moveToStart(interpolatedTime); + } + }; + + private void startScaleDownReturnToStartAnimation(int from, + AnimationListener listener) { + mFrom = from; + if (isAlphaUsedForScale()) { + mStartingScale = mProgress.getAlpha(); + } else { + mStartingScale = ViewCompat.getScaleX(mCircleView); + } + mScaleDownToStartAnimation = new Animation() { + @Override + public void applyTransformation(float interpolatedTime, Transformation t) { + float targetScale = (mStartingScale + (-mStartingScale * interpolatedTime)); + setAnimationProgress(targetScale); + moveToStart(interpolatedTime); + } + }; + mScaleDownToStartAnimation.setDuration(SCALE_DOWN_DURATION); + if (listener != null) { + mCircleView.setAnimationListener(listener); + } + mCircleView.clearAnimation(); + mCircleView.startAnimation(mScaleDownToStartAnimation); + } + + private void setTargetOffsetTopAndBottom(int offset, boolean requiresUpdate) { + mCircleView.bringToFront(); + mCircleView.offsetTopAndBottom(offset); + mCurrentTargetOffsetTop = mCircleView.getTop(); + if (requiresUpdate && android.os.Build.VERSION.SDK_INT < 11) { + invalidate(); + } + } + + private void onSecondaryPointerUp(MotionEvent ev) { + final int pointerIndex = MotionEventCompat.getActionIndex(ev); + final int pointerId = MotionEventCompat.getPointerId(ev, pointerIndex); + if (pointerId == mActivePointerId) { + // This was our active pointer going up. Choose a new + // active pointer and adjust accordingly. + final int newPointerIndex = pointerIndex == 0 ? 1 : 0; + mActivePointerId = MotionEventCompat.getPointerId(ev, newPointerIndex); + } + } + + /** + * Classes that wish to be notified when the swipe gesture correctly + * triggers a refresh should implement this interface. + */ + public interface OnHeaderRefreshListener { + public void onHeaderRefresh(); + } + + public interface OnFooterRefreshListener { + public boolean onFooterRefresh(); + } +} diff --git a/app/src/main/java/com/hippo/widget/refreshlayout/SwipeProgressBar.java b/app/src/main/java/com/hippo/widget/refreshlayout/SwipeProgressBar.java new file mode 100644 index 000000000..6b1454304 --- /dev/null +++ b/app/src/main/java/com/hippo/widget/refreshlayout/SwipeProgressBar.java @@ -0,0 +1,275 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hippo.widget.refreshlayout; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.RectF; +import android.support.v4.view.ViewCompat; +import android.view.View; +import android.view.animation.AnimationUtils; +import android.view.animation.Interpolator; + + +/** + * Custom progress bar that shows a cycle of colors as widening circles that + * overdraw each other. When finished, the bar is cleared from the inside out as + * the main cycle continues. Before running, this can also indicate how close + * the user is to triggering something (e.g. how far they need to pull down to + * trigger a refresh). + */ +final class SwipeProgressBar { + + // Default progress animation colors are grays. + private final static int COLOR1 = 0xB3000000; + private final static int COLOR2 = 0x80000000; + private final static int COLOR3 = 0x4d000000; + private final static int COLOR4 = 0x1a000000; + + // The duration of the animation cycle. + private static final int ANIMATION_DURATION_MS = 2000; + + // The duration of the animation to clear the bar. + private static final int FINISH_ANIMATION_DURATION_MS = 1000; + + // Interpolator for varying the speed of the animation. + private static final Interpolator INTERPOLATOR = BakedBezierInterpolator.getInstance(); + + private final Paint mPaint = new Paint(); + private final RectF mClipRect = new RectF(); + private float mTriggerPercentage; + private long mStartTime; + private long mFinishTime; + private boolean mRunning; + + // Colors used when rendering the animation, + private int mColor1; + private int mColor2; + private int mColor3; + private int mColor4; + private View mParent; + + private Rect mBounds = new Rect(); + + public SwipeProgressBar(View parent) { + mParent = parent; + mColor1 = COLOR1; + mColor2 = COLOR2; + mColor3 = COLOR3; + mColor4 = COLOR4; + } + + /** + * Set the four colors used in the progress animation. The first color will + * also be the color of the bar that grows in response to a user swipe + * gesture. + * + * @param color1 Integer representation of a color. + * @param color2 Integer representation of a color. + * @param color3 Integer representation of a color. + * @param color4 Integer representation of a color. + */ + void setColorScheme(int color1, int color2, int color3, int color4) { + mColor1 = color1; + mColor2 = color2; + mColor3 = color3; + mColor4 = color4; + } + + /** + * Update the progress the user has made toward triggering the swipe + * gesture. and use this value to update the percentage of the trigger that + * is shown. + */ + void setTriggerPercentage(float triggerPercentage) { + mTriggerPercentage = triggerPercentage; + mStartTime = 0; + ViewCompat.postInvalidateOnAnimation( + mParent, mBounds.left, mBounds.top, mBounds.right, mBounds.bottom); + } + + /** + * Start showing the progress animation. + */ + void start() { + if (!mRunning) { + mTriggerPercentage = 0; + mStartTime = AnimationUtils.currentAnimationTimeMillis(); + mRunning = true; + mParent.postInvalidate(); + } + } + + /** + * Stop showing the progress animation. + */ + void stop() { + if (mRunning) { + mTriggerPercentage = 0; + mFinishTime = AnimationUtils.currentAnimationTimeMillis(); + mRunning = false; + mParent.postInvalidate(); + } + } + + /** + * @return Return whether the progress animation is currently running. + */ + boolean isRunning() { + return mRunning || mFinishTime > 0; + } + + void draw(Canvas canvas) { + final int width = mBounds.width(); + final int height = mBounds.height(); + final int cx = width / 2; + final int cy = height / 2; + boolean drawTriggerWhileFinishing = false; + int restoreCount = canvas.save(); + canvas.clipRect(mBounds); + + if (mRunning || (mFinishTime > 0)) { + long now = AnimationUtils.currentAnimationTimeMillis(); + long elapsed = (now - mStartTime) % ANIMATION_DURATION_MS; + long iterations = (now - mStartTime) / ANIMATION_DURATION_MS; + float rawProgress = (elapsed / (ANIMATION_DURATION_MS / 100f)); + + // If we're not running anymore, that means we're running through + // the finish animation. + if (!mRunning) { + // If the finish animation is done, don't draw anything, and + // don't repost. + if ((now - mFinishTime) >= FINISH_ANIMATION_DURATION_MS) { + mFinishTime = 0; + return; + } + + // Otherwise, use a 0 opacity alpha layer to clear the animation + // from the inside out. This layer will prevent the circles from + // drawing within its bounds. + long finishElapsed = (now - mFinishTime) % FINISH_ANIMATION_DURATION_MS; + float finishProgress = (finishElapsed / (FINISH_ANIMATION_DURATION_MS / 100f)); + float pct = (finishProgress / 100f); + // Radius of the circle is half of the screen. + float clearRadius = width / 2 * INTERPOLATOR.getInterpolation(pct); + mClipRect.set(cx - clearRadius, 0, cx + clearRadius, height); + canvas.saveLayerAlpha(mClipRect, 0, 0); + // Only draw the trigger if there is a space in the center of + // this refreshing view that needs to be filled in by the + // trigger. If the progress view is just still animating, let it + // continue animating. + drawTriggerWhileFinishing = true; + } + + // First fill in with the last color that would have finished drawing. + if (iterations == 0) { + canvas.drawColor(mColor1); + } else { + if (rawProgress >= 0 && rawProgress < 25) { + canvas.drawColor(mColor4); + } else if (rawProgress >= 25 && rawProgress < 50) { + canvas.drawColor(mColor1); + } else if (rawProgress >= 50 && rawProgress < 75) { + canvas.drawColor(mColor2); + } else { + canvas.drawColor(mColor3); + } + } + + // Then draw up to 4 overlapping concentric circles of varying radii, based on how far + // along we are in the cycle. + // progress 0-50 draw mColor2 + // progress 25-75 draw mColor3 + // progress 50-100 draw mColor4 + // progress 75 (wrap to 25) draw mColor1 + if ((rawProgress >= 0 && rawProgress <= 25)) { + float pct = (((rawProgress + 25) * 2) / 100f); + drawCircle(canvas, cx, cy, mColor1, pct); + } + if (rawProgress >= 0 && rawProgress <= 50) { + float pct = ((rawProgress * 2) / 100f); + drawCircle(canvas, cx, cy, mColor2, pct); + } + if (rawProgress >= 25 && rawProgress <= 75) { + float pct = (((rawProgress - 25) * 2) / 100f); + drawCircle(canvas, cx, cy, mColor3, pct); + } + if (rawProgress >= 50 && rawProgress <= 100) { + float pct = (((rawProgress - 50) * 2) / 100f); + drawCircle(canvas, cx, cy, mColor4, pct); + } + if ((rawProgress >= 75 && rawProgress <= 100)) { + float pct = (((rawProgress - 75) * 2) / 100f); + drawCircle(canvas, cx, cy, mColor1, pct); + } + if (mTriggerPercentage > 0 && drawTriggerWhileFinishing) { + // There is some portion of trigger to draw. Restore the canvas, + // then draw the trigger. Otherwise, the trigger does not appear + // until after the bar has finished animating and appears to + // just jump in at a larger width than expected. + canvas.restoreToCount(restoreCount); + restoreCount = canvas.save(); + canvas.clipRect(mBounds); + drawTrigger(canvas, cx, cy); + } + // Keep running until we finish out the last cycle. + ViewCompat.postInvalidateOnAnimation( + mParent, mBounds.left, mBounds.top, mBounds.right, mBounds.bottom); + } else { + // Otherwise if we're in the middle of a trigger, draw that. + if (mTriggerPercentage > 0 && mTriggerPercentage <= 1.0) { + drawTrigger(canvas, cx, cy); + } + } + canvas.restoreToCount(restoreCount); + } + + private void drawTrigger(Canvas canvas, int cx, int cy) { + mPaint.setColor(mColor1); + canvas.drawCircle(cx, cy, cx * mTriggerPercentage, mPaint); + } + + /** + * Draws a circle centered in the view. + * + * @param canvas the canvas to draw on + * @param cx the center x coordinate + * @param cy the center y coordinate + * @param color the color to draw + * @param pct the percentage of the view that the circle should cover + */ + private void drawCircle(Canvas canvas, float cx, float cy, int color, float pct) { + mPaint.setColor(color); + canvas.save(); + canvas.translate(cx, mParent.getHeight() - cy); + float radiusScale = INTERPOLATOR.getInterpolation(pct); + canvas.scale(radiusScale, radiusScale); + canvas.drawCircle(0, 0, cx, mPaint); + canvas.restore(); + } + + /** + * Set the drawing bounds of this SwipeProgressBar. + */ + void setBounds(int left, int top, int right, int bottom) { + mBounds.left = left; + mBounds.top = top; + mBounds.right = right; + mBounds.bottom = bottom; + } +}