Created
November 17, 2015 07:55
-
-
Save talenguyen/87615ea581a19b205c08 to your computer and use it in GitHub Desktop.
Custom SwipeRefresh
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import android.content.Context; | |
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 = 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. | |
* | |
* @param colorRes Id of a color resource. | |
*/ | |
public void setBackgroundColorRes(int colorRes) { | |
setBackgroundColor(getContext().getResources().getColor(colorRes)); | |
} | |
@Override | |
public void setBackgroundColor(int color) { | |
if (getBackground() instanceof ShapeDrawable) { | |
((ShapeDrawable) getBackground()).getPaint().setColor(color); | |
} | |
} | |
private class OvalShadow extends OvalShape { | |
private RadialGradient mRadialGradient; | |
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); | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.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.support.v4.view.animation.FastOutSlowInInterpolator; | |
import android.util.DisplayMetrics; | |
import android.view.View; | |
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 MATERIAL_INTERPOLATOR = new FastOutSlowInInterpolator(); | |
private static final float FULL_ROTATION = 1080.0f; | |
@Retention(RetentionPolicy.CLASS) | |
@IntDef({LARGE, DEFAULT}) | |
public @interface ProgressDrawableSize {} | |
// Maps to ProgressBar.Large style | |
public static final int LARGE = 0; | |
// Maps to ProgressBar default style | |
public 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 value in the linear interpolator for animating the drawable at which | |
* the color transition should start | |
*/ | |
private static final float COLOR_START_DELAY_OFFSET = 0.75f; | |
private static final float END_TRIM_START_DELAY_OFFSET = 0.5f; | |
private static final float START_TRIM_DURATION_OFFSET = 0.5f; | |
/** The duration of a single progress spin in milliseconds. */ | |
private static final int ANIMATION_DURATION = 1332; | |
/** 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<Animation> mAnimators = new ArrayList<Animation>(); | |
/** 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 MaterialProgressDrawable.LARGE} or | |
* {@link MaterialProgressDrawable.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<Animation> 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 float getMinProgressArc(Ring ring) { | |
return (float) Math.toRadians( | |
ring.getStrokeWidth() / (2 * Math.PI * ring.getCenterRadius())); | |
} | |
// Adapted from ArgbEvaluator.java | |
private int evaluateColorChange(float fraction, int startValue, int endValue) { | |
int startInt = (Integer) startValue; | |
int startA = (startInt >> 24) & 0xff; | |
int startR = (startInt >> 16) & 0xff; | |
int startG = (startInt >> 8) & 0xff; | |
int startB = startInt & 0xff; | |
int endInt = (Integer) endValue; | |
int endA = (endInt >> 24) & 0xff; | |
int endR = (endInt >> 16) & 0xff; | |
int endG = (endInt >> 8) & 0xff; | |
int endB = endInt & 0xff; | |
return (int)((startA + (int)(fraction * (endA - startA))) << 24) | | |
(int)((startR + (int)(fraction * (endR - startR))) << 16) | | |
(int)((startG + (int)(fraction * (endG - startG))) << 8) | | |
(int)((startB + (int)(fraction * (endB - startB)))); | |
} | |
/** | |
* Update the ring color if this is within the last 25% of the animation. | |
* The new ring color will be a translation from the starting ring color to | |
* the next color. | |
*/ | |
private void updateRingColor(float interpolatedTime, Ring ring) { | |
if (interpolatedTime > COLOR_START_DELAY_OFFSET) { | |
// scale the interpolatedTime so that the full | |
// transformation from 0 - 1 takes place in the | |
// remaining time | |
ring.setColor(evaluateColorChange((interpolatedTime - COLOR_START_DELAY_OFFSET) | |
/ (1.0f - COLOR_START_DELAY_OFFSET), ring.getStartingColor(), | |
ring.getNextColor())); | |
} | |
} | |
private void applyFinishTranslation(float interpolatedTime, Ring ring) { | |
// shrink back down and complete a full rotation before | |
// starting other circles | |
// Rotation goes between [0..1]. | |
updateRingColor(interpolatedTime, ring); | |
float targetRotation = (float) (Math.floor(ring.getStartingRotation() / MAX_PROGRESS_ARC) | |
+ 1f); | |
final float minProgressArc = getMinProgressArc(ring); | |
final float startTrim = ring.getStartingStartTrim() | |
+ (ring.getStartingEndTrim() - minProgressArc - ring.getStartingStartTrim()) | |
* interpolatedTime; | |
ring.setStartTrim(startTrim); | |
ring.setEndTrim(ring.getStartingEndTrim()); | |
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 = getMinProgressArc(ring); | |
final float startingEndTrim = ring.getStartingEndTrim(); | |
final float startingTrim = ring.getStartingStartTrim(); | |
final float startingRotation = ring.getStartingRotation(); | |
updateRingColor(interpolatedTime, ring); | |
// Moving the start trim only occurs in the first 50% of a | |
// single ring animation | |
if (interpolatedTime <= START_TRIM_DURATION_OFFSET) { | |
// scale the interpolatedTime so that the full | |
// transformation from 0 - 1 takes place in the | |
// remaining time | |
final float scaledTime = (interpolatedTime) | |
/ (1.0f - START_TRIM_DURATION_OFFSET); | |
final float startTrim = startingTrim | |
+ ((MAX_PROGRESS_ARC - minProgressArc) * MATERIAL_INTERPOLATOR | |
.getInterpolation(scaledTime)); | |
ring.setStartTrim(startTrim); | |
} | |
// Moving the end trim starts after 50% of a single ring | |
// animation completes | |
if (interpolatedTime > END_TRIM_START_DELAY_OFFSET) { | |
// scale the interpolatedTime so that the full | |
// transformation from 0 - 1 takes place in the | |
// remaining time | |
final float minArc = MAX_PROGRESS_ARC - minProgressArc; | |
float scaledTime = (interpolatedTime - START_TRIM_DURATION_OFFSET) | |
/ (1.0f - START_TRIM_DURATION_OFFSET); | |
final float endTrim = startingEndTrim | |
+ (minArc * MATERIAL_INTERPOLATOR.getInterpolation(scaledTime)); | |
ring.setEndTrim(endTrim); | |
} | |
final float rotation = startingRotation + (0.25f * interpolatedTime); | |
ring.setRotation(rotation); | |
float groupRotation = ((FULL_ROTATION / NUM_POINTS) * interpolatedTime) | |
+ (FULL_ROTATION * (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(Paint.ANTI_ALIAS_FLAG); | |
private int mBackgroundColor; | |
private int mCurrentColor; | |
public Ring(Callback callback) { | |
mCallback = callback; | |
mPaint.setStrokeCap(Paint.Cap.SQUARE); | |
mPaint.setAntiAlias(true); | |
mPaint.setStyle(Paint.Style.STROKE); | |
mArrowPaint.setStyle(Paint.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(mCurrentColor); | |
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(mCurrentColor); | |
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-<code>null</code>. | |
*/ | |
public void setColors(@NonNull int[] colors) { | |
mColors = colors; | |
// if colors are reset, make sure to reset the color index as well | |
setColorIndex(0); | |
} | |
/** | |
* Set the absolute color of the progress spinner. This is should only | |
* be used when animating between current and next color when the | |
* spinner is rotating. | |
* | |
* @param color int describing the color. | |
*/ | |
public void setColor(int color) { | |
mCurrentColor = color; | |
} | |
/** | |
* @param index Index into the color array of the color to display in | |
* the progress spinner. | |
*/ | |
public void setColorIndex(int index) { | |
mColorIndex = index; | |
mCurrentColor = mColors[mColorIndex]; | |
} | |
/** | |
* @return int describing the next color the progress spinner should use when drawing. | |
*/ | |
public int getNextColor() { | |
return mColors[getNextColorIndex()]; | |
} | |
private int getNextColorIndex() { | |
return (mColorIndex + 1) % (mColors.length); | |
} | |
/** | |
* Proceed to the next available ring color. This will automatically | |
* wrap back to the beginning of colors. | |
*/ | |
public void goToNextColor() { | |
setColorIndex(getNextColorIndex()); | |
} | |
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; | |
} | |
public int getStartingColor() { | |
return mColors[mColorIndex]; | |
} | |
@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); | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import android.content.Context; | |
import android.content.res.Resources; | |
import android.content.res.TypedArray; | |
import android.support.annotation.ColorInt; | |
import android.support.annotation.ColorRes; | |
import android.support.v4.view.MotionEventCompat; | |
import android.support.v4.view.NestedScrollingChild; | |
import android.support.v4.view.NestedScrollingChildHelper; | |
import android.support.v4.view.NestedScrollingParent; | |
import android.support.v4.view.NestedScrollingParentHelper; | |
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.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. | |
* <p> | |
* 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. | |
* </p> | |
*/ | |
public class SwipeRefreshLayout extends ViewGroup implements NestedScrollingParent, | |
NestedScrollingChild { | |
// 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 TAG = SwipeRefreshLayout.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 View mTarget; // the target of the gesture | |
private android.support.v4.widget.SwipeRefreshLayout.OnRefreshListener mListener; | |
private boolean mRefreshing = false; | |
private int mTouchSlop; | |
private float mTotalDragDistance = -1; | |
// If nested scrolling is enabled, the total amount that needed to be | |
// consumed by this as the nested scrolling parent is used in place of the | |
// overscroll determined by MOVE events in the onTouch handler | |
private float mTotalUnconsumed; | |
private final NestedScrollingParentHelper mNestedScrollingParentHelper; | |
private final NestedScrollingChildHelper mNestedScrollingChildHelper; | |
private final int[] mParentScrollConsumed = new int[2]; | |
private int mMediumAnimationDuration; | |
private int mCurrentTargetOffsetTop; | |
// Whether or not the starting offset has been determined. | |
private boolean mOriginalOffsetCalculated = false; | |
private float mInitialMotionY; | |
private float mInitialDownY; | |
private boolean mIsBeingDragged; | |
private int mActivePointerId = INVALID_POINTER; | |
// Whether this item is scaled up rather than clipped | |
private boolean mScale; | |
// Target is returning to its start offset because it was cancelled or a | |
// refresh was triggered. | |
private boolean mReturningToStart; | |
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 Animation.AnimationListener mRefreshListener = new Animation.AnimationListener() { | |
@Override | |
public void onAnimationStart(Animation animation) { | |
} | |
@Override | |
public void onAnimationRepeat(Animation animation) { | |
} | |
@Override | |
public void onAnimationEnd(Animation animation) { | |
if (mRefreshing) { | |
// Make sure the progress view is fully visible | |
mProgress.setAlpha(MAX_ALPHA); | |
mProgress.start(); | |
if (mNotify) { | |
if (mListener != null) { | |
mListener.onRefresh(); | |
} | |
} | |
} 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 SwipeRefreshLayout(Context context) { | |
this(context, null); | |
} | |
/** | |
* Constructor that is called when inflating SwipeRefreshLayout from XML. | |
* | |
* @param context | |
* @param attrs | |
*/ | |
public SwipeRefreshLayout(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); | |
createProgressView(); | |
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; | |
mNestedScrollingParentHelper = new NestedScrollingParentHelper(this); | |
mNestedScrollingChildHelper = new NestedScrollingChildHelper(this); | |
setNestedScrollingEnabled(true); | |
} | |
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 createProgressView() { | |
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, 0); | |
} | |
/** | |
* Set the listener to be notified when a refresh is triggered via the swipe | |
* gesture. | |
*/ | |
public void setOnRefreshListener( | |
android.support.v4.widget.SwipeRefreshLayout.OnRefreshListener listener) { | |
mListener = 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; | |
} | |
/** | |
* 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 setRefreshing(boolean refreshing) { | |
if (refreshing && mRefreshing != refreshing) { | |
// scale and show | |
mRefreshing = 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 { | |
setRefreshing(refreshing, false /* notify */); | |
} | |
} | |
private void startScaleUpAnimation(Animation.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 setRefreshing(boolean refreshing, final boolean notify) { | |
if (mRefreshing != refreshing) { | |
mNotify = notify; | |
ensureTarget(); | |
mRefreshing = refreshing; | |
if (mRefreshing) { | |
animateOffsetToCorrectPosition(mCurrentTargetOffsetTop, mRefreshListener); | |
} else { | |
startScaleDownAnimation(mRefreshListener); | |
} | |
} | |
} | |
private void startScaleDownAnimation(Animation.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; | |
} | |
/** | |
* @deprecated Use {@link #setProgressBackgroundColorSchemeResource(int)} | |
*/ | |
@Deprecated | |
public void setProgressBackgroundColor(int colorRes) { | |
setProgressBackgroundColorSchemeResource(colorRes); | |
} | |
/** | |
* Set the background color of the progress spinner disc. | |
* | |
* @param colorRes Resource id of the color. | |
*/ | |
public void setProgressBackgroundColorSchemeResource(@ColorRes int colorRes) { | |
setProgressBackgroundColorSchemeColor(getResources().getColor(colorRes)); | |
} | |
/** | |
* Set the background color of the progress spinner disc. | |
* | |
* @param color | |
*/ | |
public void setProgressBackgroundColorSchemeColor(@ColorInt int color) { | |
mCircleView.setBackgroundColor(color); | |
mProgress.setBackgroundColor(color); | |
} | |
/** | |
* 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 setColorSchemeResources(@ColorRes 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]); | |
} | |
setColorSchemeColors(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 | |
*/ | |
@ColorInt | |
public void setColorSchemeColors(int... colors) { | |
ensureTarget(); | |
mProgress.setColorSchemeColors(colors); | |
} | |
/** | |
* @return Whether the SwipeRefreshWidget is actively showing refresh | |
* progress. | |
*/ | |
public boolean isRefreshing() { | |
return mRefreshing; | |
} | |
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 | |
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; | |
} | |
int circleWidth = mCircleView.getMeasuredWidth(); | |
int circleHeight = mCircleView.getMeasuredHeight(); | |
mCircleView.layout((width / 2 - circleWidth / 2), mCurrentTargetOffsetTop, | |
(width / 2 + circleWidth / 2), mCurrentTargetOffsetTop + circleHeight); | |
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); | |
} | |
@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; | |
} | |
} | |
} | |
/** | |
* Get the diameter of the progress circle that is displayed as part of the | |
* swipe to refresh layout. This is not valid until a measure pass has | |
* completed. | |
* | |
* @return Diameter in pixels of the progress circle view. | |
*/ | |
public int getProgressCircleDiameter() { | |
return mCircleView != null ?mCircleView.getMeasuredHeight() : 0; | |
} | |
/** | |
* @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 ViewCompat.canScrollVertically(mTarget, -1) || mTarget.getScrollY() > 0; | |
} | |
} else { | |
return ViewCompat.canScrollVertically(mTarget, -1); | |
} | |
} | |
@Override | |
public boolean onInterceptTouchEvent(MotionEvent ev) { | |
ensureTarget(); | |
final int action = MotionEventCompat.getActionMasked(ev); | |
if (mReturningToStart && action == MotionEvent.ACTION_DOWN) { | |
mReturningToStart = false; | |
} | |
if (!isEnabled() || mReturningToStart || canChildScrollUp() || mRefreshing) { | |
// 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 initialDownY = getMotionEventY(ev, mActivePointerId); | |
if (initialDownY == -1) { | |
return false; | |
} | |
mInitialDownY = initialDownY; | |
break; | |
case MotionEvent.ACTION_MOVE: | |
if (mActivePointerId == INVALID_POINTER) { | |
Log.e(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 - mInitialDownY; | |
if (yDiff > mTouchSlop && !mIsBeingDragged) { | |
mInitialMotionY = mInitialDownY + mTouchSlop; | |
mIsBeingDragged = true; | |
mProgress.setAlpha(STARTING_PROGRESS_ALPHA); | |
} | |
break; | |
case MotionEventCompat.ACTION_POINTER_UP: | |
onSecondaryPointerUp(ev); | |
break; | |
case MotionEvent.ACTION_UP: | |
case MotionEvent.ACTION_CANCEL: | |
Log.d(TAG, "onInterceptTouchEvent() called with: " + "ev = [" + ev + "]"); | |
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) { | |
// if this is a List < L or another view that doesn't support nested | |
// scrolling, ignore this request so that the vertical scroll event | |
// isn't stolen | |
if ((android.os.Build.VERSION.SDK_INT < 21 && mTarget instanceof AbsListView) | |
|| (mTarget != null && !ViewCompat.isNestedScrollingEnabled(mTarget))) { | |
// Nope. | |
} else { | |
super.requestDisallowInterceptTouchEvent(b); | |
} | |
} | |
// NestedScrollingParent | |
@Override | |
public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) { | |
if (isEnabled() && (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0) { | |
// Dispatch up to the nested parent | |
startNestedScroll(nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL); | |
return true; | |
} | |
return false; | |
} | |
@Override | |
public void onNestedScrollAccepted(View child, View target, int axes) { | |
// Reset the counter of how much leftover scroll needs to be consumed. | |
mNestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes); | |
mTotalUnconsumed = 0; | |
} | |
@Override | |
public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) { | |
// If we are in the middle of consuming, a scroll, then we want to move the spinner back up | |
// before allowing the list to scroll | |
if (dy > 0 && mTotalUnconsumed > 0) { | |
if (dy > mTotalUnconsumed) { | |
consumed[1] = dy - (int) mTotalUnconsumed; | |
mTotalUnconsumed = 0; | |
} else { | |
mTotalUnconsumed -= dy; | |
consumed[1] = dy; | |
} | |
moveSpinner(mTotalUnconsumed); | |
} | |
// Now let our nested parent consume the leftovers | |
final int[] parentConsumed = mParentScrollConsumed; | |
if (dispatchNestedPreScroll(dx - consumed[0], dy - consumed[1], parentConsumed, null)) { | |
consumed[0] += parentConsumed[0]; | |
consumed[1] += parentConsumed[1]; | |
} | |
} | |
@Override | |
public int getNestedScrollAxes() { | |
return mNestedScrollingParentHelper.getNestedScrollAxes(); | |
} | |
@Override | |
public void onStopNestedScroll(View target) { | |
mNestedScrollingParentHelper.onStopNestedScroll(target); | |
// Finish the spinner for nested scrolling if we ever consumed any | |
// unconsumed nested scroll | |
if (mTotalUnconsumed > 0) { | |
finishSpinner(mTotalUnconsumed); | |
mTotalUnconsumed = 0; | |
} | |
// Dispatch up our nested parent | |
stopNestedScroll(); | |
} | |
@Override | |
public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, | |
int dyUnconsumed) { | |
if (dyUnconsumed < 0) { | |
dyUnconsumed = Math.abs(dyUnconsumed); | |
mTotalUnconsumed += dyUnconsumed; | |
moveSpinner(mTotalUnconsumed); | |
} | |
// Dispatch up to the nested parent | |
dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dxConsumed, null); | |
} | |
// NestedScrollingChild | |
@Override | |
public boolean onNestedPreFling(View target, float velocityX, float velocityY) { | |
return false; | |
} | |
@Override | |
public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed) { | |
return false; | |
} | |
@Override | |
public void setNestedScrollingEnabled(boolean enabled) { | |
mNestedScrollingChildHelper.setNestedScrollingEnabled(enabled); | |
} | |
@Override | |
public boolean isNestedScrollingEnabled() { | |
return mNestedScrollingChildHelper.isNestedScrollingEnabled(); | |
} | |
@Override | |
public boolean startNestedScroll(int axes) { | |
return mNestedScrollingChildHelper.startNestedScroll(axes); | |
} | |
@Override | |
public void stopNestedScroll() { | |
mNestedScrollingChildHelper.stopNestedScroll(); | |
} | |
@Override | |
public boolean hasNestedScrollingParent() { | |
return mNestedScrollingChildHelper.hasNestedScrollingParent(); | |
} | |
@Override | |
public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, | |
int dyUnconsumed, int[] offsetInWindow) { | |
return mNestedScrollingChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, | |
dxUnconsumed, dyUnconsumed, offsetInWindow); | |
} | |
@Override | |
public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) { | |
return mNestedScrollingChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow); | |
} | |
@Override | |
public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) { | |
return mNestedScrollingChildHelper.dispatchNestedFling(velocityX, velocityY, consumed); | |
} | |
@Override | |
public boolean dispatchNestedPreFling(float velocityX, float velocityY) { | |
return mNestedScrollingChildHelper.dispatchNestedPreFling(velocityX, velocityY); | |
} | |
private boolean isAnimationRunning(Animation animation) { | |
return animation != null && animation.hasStarted() && !animation.hasEnded(); | |
} | |
private void moveSpinner(float overscrollTop) { | |
mProgress.showArrow(true); | |
float originalDragPercent = overscrollTop / mTotalDragDistance; | |
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 = 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 */); | |
} | |
private void finishSpinner(float overscrollTop) { | |
if (overscrollTop > mTotalDragDistance) { | |
setRefreshing(true, true /* notify */); | |
} else { | |
// cancel refresh | |
mRefreshing = false; | |
mProgress.setStartEndTrim(0f, 0f); | |
Animation.AnimationListener listener = null; | |
if (!mScale) { | |
listener = new Animation.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); | |
} | |
} | |
@Override | |
public boolean onTouchEvent(MotionEvent ev) { | |
final int action = MotionEventCompat.getActionMasked(ev); | |
if (mReturningToStart && action == MotionEvent.ACTION_DOWN) { | |
mReturningToStart = false; | |
} | |
if (!isEnabled() || mReturningToStart || canChildScrollUp()) { | |
// 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(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) { | |
if (overscrollTop > 0) { | |
moveSpinner(overscrollTop); | |
} else { | |
return false; | |
} | |
} | |
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: { | |
Log.d(TAG, "onTouchEvent() called with: " + "ev = [" + ev + "]"); | |
if (mActivePointerId == INVALID_POINTER) { | |
if (action == MotionEvent.ACTION_UP) { | |
Log.e(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; | |
finishSpinner(overscrollTop); | |
mActivePointerId = INVALID_POINTER; | |
return false; | |
} | |
} | |
return true; | |
} | |
private void animateOffsetToCorrectPosition(int from, Animation.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 peek(int from, Animation.AnimationListener listener) { | |
mFrom = from; | |
mPeek.reset(); | |
mPeek.setDuration(500); | |
mPeek.setInterpolator(mDecelerateInterpolator); | |
if (listener != null) { | |
mCircleView.setAnimationListener(listener); | |
} | |
mCircleView.clearAnimation(); | |
mCircleView.startAnimation(mPeek); | |
} | |
private void animateOffsetToStartPosition(int from, Animation.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 */); | |
mProgress.setArrowScale(1 - interpolatedTime); | |
} | |
}; | |
private final Animation mPeek = 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; //mSpinnerFinalOffset; | |
} | |
targetTop = (mFrom + (int) ((endTarget - mFrom) * interpolatedTime)); | |
int offset = targetTop - mCircleView.getTop(); | |
setTargetOffsetTopAndBottom(offset, false /* requires update */); | |
mProgress.setArrowScale(1 - interpolatedTime); | |
} | |
}; | |
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, | |
Animation.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 float targetTranslateY = 0; | |
private void setTargetOffsetTopAndBottom(int offset, boolean requiresUpdate) { | |
if (targetTranslateY >= mTotalDragDistance && offset > 0) { | |
return; | |
} | |
final boolean quickMove = | |
requiresUpdate && (targetTranslateY == 0 && offset >= mTotalDragDistance); | |
targetTranslateY += offset; | |
if (targetTranslateY == mTotalDragDistance) { | |
targetTranslateY = | |
mTotalDragDistance; // This to make sure we not scroll over mTotalDragDistance | |
} | |
if (quickMove) { | |
mTarget.animate().setDuration(ANIMATE_TO_START_DURATION).translationY(targetTranslateY); | |
} else { | |
mTarget.setTranslationY(targetTranslateY); | |
} | |
mCircleView.bringToFront(); | |
mCircleView.offsetTopAndBottom(offset); | |
mCurrentTargetOffsetTop = mCircleView.getTop(); | |
if (requiresUpdate && android.os.Build.VERSION.SDK_INT < 11) { | |
invalidate(); | |
} | |
Log.d(TAG, "setTargetOffsetTopAndBottom() called with: " | |
+ "targetTranslateY = [" | |
+ targetTranslateY | |
+ "], requiresUpdate = [" | |
+ requiresUpdate | |
+ "]"); | |
if (targetTranslateY == 0) { | |
ViewCompat.setScaleX(mCircleView, 0); | |
ViewCompat.setScaleY(mCircleView, 0); | |
} | |
} | |
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); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment