package com.marverenic.music.view;
import android.animation.IntEvaluator;
import android.animation.ObjectAnimator;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Point;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.support.annotation.ColorInt;
import android.support.annotation.Keep;
import android.support.annotation.Nullable;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.animation.AnimationUtils;
import android.widget.FrameLayout;
import com.marverenic.music.R;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
/**
* GestureView is a {@link FrameLayout} that will respond to horizontal swipe gestures, display
* a visual hint, and use a callback specified by {@link #setGestureListener(OnGestureListener)}. All
* touch events that this view receives are consumed and may be used to start a swipe.
*
* When using this view in an xml layout file, you can specify additional attributes to define this
* View's behavior including <code>leftIndicator</code> and <code>rightIndicator</code>, which are
* equivalent to calling {@link #setLeftIndicator(Drawable)} and
* {@link #setRightIndicator(Drawable)}.
*/
public class GestureView extends FrameLayout {
private static final int MIN_RELEASE_THRESHOLD_DP = 16;
private static final int ACTIVATION_THRESHOLD_DP = 96;
private static final int INDICATOR_SIZE_DP = 36;
private static final int DEFAULT_COLOR = Color.BLACK;
private static final int TAP_DURATION_MS = 1000;
private static final int MAX_TAP_MOVEMENT_DP = 10;
private boolean mEnabled;
private OnGestureListener mGestureListener;
private Drawable mLeftIndicator;
private Drawable mRightIndicator;
private Drawable mTapIndicator;
private int mColor;
private GestureOverlay mActiveOverlay;
private List<GestureOverlay> mAnimatingOverlays;
private List<GestureOverlay> mOverlayPool;
private int mMinReleaseThreshold;
private int mIndicatorSize;
private int mActivationThreshold;
public GestureView(Context context) {
this(context, null);
}
public GestureView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public GestureView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mAnimatingOverlays = new ArrayList<>();
mOverlayPool = new ArrayList<>();
float densityMultiplier = getResources().getDisplayMetrics().density;
mMinReleaseThreshold = (int) (MIN_RELEASE_THRESHOLD_DP * densityMultiplier);
mIndicatorSize = (int) (INDICATOR_SIZE_DP * densityMultiplier);
mActivationThreshold = (int) (ACTIVATION_THRESHOLD_DP * densityMultiplier);
TypedArray a = context.getTheme().obtainStyledAttributes(
attrs,
R.styleable.GestureView,
0, 0);
try {
setLeftIndicator(a.getDrawable(R.styleable.GestureView_leftIndicator));
setRightIndicator(a.getDrawable(R.styleable.GestureView_rightIndicator));
setTapIndicator(a.getDrawable(R.styleable.GestureView_tapIndicator));
setColor(a.getColor(R.styleable.GestureView_overlayColor, DEFAULT_COLOR));
} finally {
a.recycle();
}
}
public void setGesturesEnabled(boolean enable) {
mEnabled = enable;
}
public void setGestureListener(@Nullable OnGestureListener listener) {
mGestureListener = listener;
}
public void setLeftIndicator(@Nullable Drawable icon) {
mLeftIndicator = icon;
}
public void setRightIndicator(@Nullable Drawable icon) {
mRightIndicator = icon;
}
public void setTapIndicator(@Nullable Drawable icon) {
mTapIndicator = icon;
}
/**
* Sets the color of the overlay background when the user is preforming a swipe gesture
* @param color A color as an integer using the standard {@link android.graphics.Color} format
*/
public void setColor(@ColorInt int color) {
mColor = color;
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
return true;
}
private GestureOverlay allocateOverlay() {
if (mOverlayPool.isEmpty()) {
return new GestureOverlay();
}
// Remove from the back for O(1) time with an ArrayList
return mOverlayPool.remove(mOverlayPool.size() - 1);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if (!mEnabled) {
return false;
}
if (event.getAction() == MotionEvent.ACTION_DOWN) {
if (mActiveOverlay == null) {
requestDisallowInterceptTouchEvent(true);
mActiveOverlay = allocateOverlay();
Point origin = new Point((int) event.getX(), (int) event.getY());
mActiveOverlay.startGesture(origin);
invalidate();
return true;
}
} else if (event.getAction() == MotionEvent.ACTION_UP) {
if (mActiveOverlay != null) {
mActiveOverlay.completeGesture();
mAnimatingOverlays.add(mActiveOverlay);
mActiveOverlay = null;
invalidate();
}
return true;
} else if (event.getAction() == MotionEvent.ACTION_CANCEL) {
if (mActiveOverlay != null) {
mActiveOverlay.cancelGesture();
mAnimatingOverlays.add(mActiveOverlay);
mActiveOverlay = null;
invalidate();
}
return false;
} else if (mActiveOverlay != null) {
mActiveOverlay.updateGesturePosition((int) event.getX(), (int) event.getY());
if (mActiveOverlay.isGestureVertical()) {
// Abort gesture and stop intercepting touch events from parent
mActiveOverlay.cancelGesture();
mAnimatingOverlays.add(mActiveOverlay);
mActiveOverlay = null;
invalidate();
requestDisallowInterceptTouchEvent(false);
return false;
} else {
invalidate();
return true;
}
}
return false;
}
@Override
public void draw(Canvas canvas) {
super.draw(canvas);
for (Iterator<GestureOverlay> it = mAnimatingOverlays.iterator(); it.hasNext(); ) {
GestureOverlay overlay = it.next();
if (overlay.isVisible()) {
overlay.draw(canvas);
} else {
it.remove();
mOverlayPool.add(overlay);
}
}
if (mActiveOverlay != null) {
mActiveOverlay.draw(canvas);
}
}
public interface OnGestureListener {
void onLeftSwipe();
void onRightSwipe();
void onTap();
}
private class GestureOverlay {
private Drawable mLeftIndicator;
private Drawable mRightIndicator;
private Drawable mTapIndicator;
private Paint mOverlayPaint;
private Point mOverlayOrigin;
private final Point mOverlayEdge;
private long mGestureStartTime;
private boolean mPreformingTap;
private boolean mAbortedTap;
private boolean mConfirmedGesture;
private int mAlpha;
public GestureOverlay() {
mOverlayPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mOverlayEdge = new Point();
setOverlayAlpha(255);
}
public void startGesture(Point origin) {
mOverlayPaint.setColor(mColor);
mLeftIndicator = GestureView.this.mLeftIndicator;
mRightIndicator = GestureView.this.mRightIndicator;
mTapIndicator = GestureView.this.mTapIndicator;
mOverlayOrigin = origin;
mOverlayEdge.set(mOverlayOrigin.x, mOverlayOrigin.y);
mGestureStartTime = System.currentTimeMillis();
mConfirmedGesture = false;
mPreformingTap = false;
mAbortedTap = false;
mAlpha = 255;
}
public void updateGesturePosition(int x, int y) {
mOverlayEdge.set(x, y);
}
public void cancelGesture() {
animateOutRadius(0);
}
public boolean isVisible() {
return mAlpha > 0;
}
public boolean isGestureVertical() {
int dY = Math.abs(mOverlayEdge.y - mOverlayOrigin.y);
int dX = Math.abs(mOverlayEdge.x - mOverlayOrigin.x);
if (dY < mMinReleaseThreshold && dX < mMinReleaseThreshold) {
mConfirmedGesture = false;
return false;
} else if (mConfirmedGesture) {
return false;
} else {
mConfirmedGesture = (dY > 2 * dX);
return mConfirmedGesture;
}
}
/**
* Called when a gesture completes. This method fires off the correct callback (if applicable),
* animates the view overlay, and clears the current gesture so that another one may be started
*/
public void completeGesture() {
if (isTap()) {
mPreformingTap = true;
if (mGestureListener != null) {
mGestureListener.onTap();
}
animateOutRadius(getWidth());
} else if (isComplete()) {
if (isLeft()) {
if (mGestureListener != null) {
mGestureListener.onLeftSwipe();
}
animateOutRadius(-1 * getWidth());
} else {
if (mGestureListener != null) {
mGestureListener.onRightSwipe();
}
animateOutRadius(getWidth());
}
} else {
animateOutRadius(0);
}
}
/**
* This method will change the opacity of the overlay. This method is used by an
* {@link ObjectAnimator} to animate completion events and usually shouldn't be used by
* external classes because it will likely be overwritten.
* (This method is public so that ObjectAnimator can find it)
* @param alpha The new alpha of the overlay
* @see #setColor(int) To change the overlay's ring color. If you need the overlay background to
* be transparent, you can set the transparency bits like a normal
* {@link android.graphics.Color} integer.
*/
@Keep
@SuppressWarnings("unused")
public void setOverlayAlpha(int alpha) {
mAlpha = alpha;
invalidate();
}
/**
* Sets the current radius of the overlay background. This method is used by an
* {@link ObjectAnimator} to animate completion events and shouldn't be used by external
* classes because it will be overwritten.
* @param radius The new radius of the background overlay
*/
@Keep
@SuppressWarnings("unused")
public void setRadius(int radius) {
if (mOverlayEdge != null && mOverlayOrigin != null) {
mOverlayEdge.x = mOverlayOrigin.x + radius;
invalidate();
}
}
/**
* @return The radius of the circle that should be drawn when a gesture has been started
*/
private int radius() {
if (mOverlayOrigin == null || mOverlayEdge == null) {
return 0;
} else {
return Math.abs(mOverlayOrigin.x - mOverlayEdge.x);
}
}
/**
* @return Whether the current swipe gesture will trigger either the left or right action if
* it was to be released right now (or if it was released)
* Returns false if no swipe gesture is currently being handled
*/
private boolean isComplete() {
return !(mOverlayEdge == null || mOverlayOrigin == null)
&& radius() > mActivationThreshold;
}
/**
* Animates the overlay to a specified radius, fades it out, and clears the current gesture
* @param targetRadius The radius to animate the circular overlay to
*/
private void animateOutRadius(int targetRadius) {
int distance = Math.abs(radius() - targetRadius);
int time = (int) (200 / getResources().getDisplayMetrics().density / distance);
animateOutRadius(targetRadius * 2, Math.max(time, 400), 300);
}
/**
* Animates the overlay to a specified radius, fades it out, and clears the current gesture
* @param targetRadius The radius to animate the circular overlay to
* @param time The time for this animation to last
* @param alphaDelay An optional delay to add before animating the transparency of the overlay
*/
private void animateOutRadius(int targetRadius, int time, int alphaDelay) {
ObjectAnimator alphaAnim = ObjectAnimator.ofObject(
this, "overlayAlpha",
new IntEvaluator(), mAlpha, 0);
ObjectAnimator radiusAnim = ObjectAnimator.ofObject(
this, "radius",
new IntEvaluator(), (isRight() ? radius() : -radius()), targetRadius);
radiusAnim
.setDuration(time)
.setInterpolator(AnimationUtils.loadInterpolator(getContext(),
android.R.interpolator.accelerate_quad));
alphaAnim
.setDuration(time)
.setInterpolator(AnimationUtils.loadInterpolator(getContext(),
android.R.interpolator.accelerate_quad));
radiusAnim.start();
alphaAnim.setStartDelay(alphaDelay);
alphaAnim.start();
}
public void draw(Canvas canvas) {
if (mOverlayOrigin != null) {
mOverlayPaint.setAlpha(mAlpha);
int radius = radius();
canvas.drawCircle(mOverlayOrigin.x, mOverlayOrigin.y, radius, mOverlayPaint);
Drawable indicator = null;
if (mPreformingTap || isTap()) {
indicator = mTapIndicator;
} else if (isLeft()) {
indicator = mLeftIndicator;
mAbortedTap = true;
} else if (isRight()) {
indicator = mRightIndicator;
mAbortedTap = true;
}
if (indicator != null) {
indicator.mutate();
int indicatorSize = Math.min(radius, mIndicatorSize) / 2;
indicator.setBounds(
mOverlayOrigin.x - indicatorSize,
mOverlayOrigin.y - indicatorSize,
mOverlayOrigin.x + indicatorSize,
mOverlayOrigin.y + indicatorSize);
float alphaMultiplier = Math.min(radius / (float) mActivationThreshold, 1);
indicator.setAlpha((int) (mAlpha * alphaMultiplier));
indicator.draw(canvas);
/*
Because RotateDrawable does not respect .mutate() on API < 23, reset the alpha
to make sure that it doesn't change the transparency of any Drawables elsewhere
in the app
*/
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
indicator.setAlpha(255);
}
}
}
}
/**
* @return True if the current gesture is a tap. This is dependent on the gesture lasting
* less than a specific duration (set in {@link #TAP_DURATION_MS}) and that the gesture
* has not moved more than a specific distance (set in {@link #MAX_TAP_MOVEMENT_DP})
*/
private boolean isTap() {
return !(mOverlayEdge == null || mOverlayOrigin == null)
&& System.currentTimeMillis() - mGestureStartTime < TAP_DURATION_MS
&& radius() < MAX_TAP_MOVEMENT_DP * getResources().getDisplayMetrics().density
&& !mAbortedTap;
}
/**
* @return True if the swipe gesture that's currently being handled is towards the left.
* If no swipe gesture's currently being handled, or the gesture doesn't have a
* direction, false will be returned.
* @see #isRight()
*/
private boolean isLeft() {
return !(mOverlayEdge == null || mOverlayOrigin == null)
&& mOverlayEdge.x < mOverlayOrigin.x;
}
/**
* @return True if the swipe gesture that's currently being handled is towards the right.
* If no swipe gesture's currently being handled, or the gesture doesn't have a
* direction, false will be returned.
* @see #isLeft()
*/
private boolean isRight() {
return !(mOverlayEdge == null || mOverlayOrigin == null)
&& mOverlayEdge.x > mOverlayOrigin.x;
}
}
}