/*
* Copyright (C) 2014 AChep@xda <artemchep@gmail.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
* MA 02110-1301, USA.
*/
package com.achep.acdisplay.ui.widgets;
import android.animation.ObjectAnimator;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.ColorFilter;
import android.graphics.ColorMatrixColorFilter;
import android.graphics.Paint;
import android.graphics.drawable.Drawable;
import android.os.Message;
import android.support.annotation.DrawableRes;
import android.support.annotation.NonNull;
import android.support.v4.graphics.ColorUtils;
import android.support.v4.view.animation.FastOutLinearInInterpolator;
import android.util.AttributeSet;
import android.util.Log;
import android.util.Property;
import android.view.HapticFeedbackConstants;
import android.view.MotionEvent;
import android.view.View;
import com.achep.acdisplay.Config;
import com.achep.acdisplay.R;
import com.achep.acdisplay.ui.CornerHelper;
import com.achep.acdisplay.ui.drawables.CornerIconDrawable;
import com.achep.base.async.WeakHandler;
import com.achep.base.tests.Check;
import com.achep.base.utils.FloatProperty;
import com.achep.base.utils.MathUtils;
import com.achep.base.utils.RefCacheBase;
import com.achep.base.utils.ResUtils;
import java.lang.ref.Reference;
import java.lang.ref.WeakReference;
import static com.achep.acdisplay.ui.preferences.ColorPickerPreference.getColor;
import static com.achep.base.Build.DEBUG;
/**
* Created by achep on 19.04.14.
*/
public class CircleView extends View {
private static final String TAG = "CircleView";
public static final int ACTION_START = 0;
public static final int ACTION_UNLOCK = 1;
public static final int ACTION_UNLOCK_START = 2;
public static final int ACTION_UNLOCK_CANCEL = 3;
public static final int ACTION_CANCELED = 4;
private static final int MSG_CANCEL = -1;
@NonNull
private static final Property<CircleView, Float> RADIUS_PROPERTY =
new FloatProperty<CircleView>("setRadius") {
@Override
public void setValue(CircleView cv, float value) {
cv.setRadius(value);
}
@Override
public Float get(CircleView cv) {
return cv.mRadius;
}
};
/**
* The current touch point.
*/
private float[] mPoint = new float[2];
/**
* Real radius of the circle, measured by touch.
*/
private float mRadius;
/**
* Radius of the drawn circle.
*
* @see #setRadiusDrawn(float)
*/
private float mRadiusDrawn;
// Target
private float mRadiusTarget;
private boolean mRadiusTargetAimed;
// Decreasing detection
private float mRadiusMaxPeak;
private float mRadiusDecreaseThreshold;
private boolean mCanceled;
private float mDarkening;
private float mCornerMargin;
@DrawableRes
private int mDrawableResourceId = -1;
private ColorFilter mInverseColorFilter;
private CornerIconDrawable mDrawableLeftTopCorner;
private CornerIconDrawable mDrawableRightTopCorner;
private CornerIconDrawable mDrawableLeftBottomCorner;
private CornerIconDrawable mDrawableRightBottomCorner;
private Drawable mDrawable;
private Paint mPaint;
@NonNull
private RefCacheBase<Drawable> mDrawableCache = new RefCacheBase<Drawable>() {
@NonNull
@Override
protected Reference<Drawable> onCreateReference(@NonNull Drawable object) {
return new WeakReference<>(object);
}
};
// animation
private ObjectAnimator mAnimator;
private int mShortAnimTime;
private int mMediumAnimTime;
private Callback mCallback;
private Supervisor mSupervisor;
private H mHandler = new H(this);
private int mInnerColor;
private int mOuterColor;
private int mCornerActionId;
public interface Callback {
void onCircleEvent(float radius, float ratio, int event, int actionId);
}
public interface Supervisor {
boolean isAnimationEnabled();
boolean isAnimationUnlockEnabled();
}
public CircleView(Context context) {
this(context, null);
}
public CircleView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public CircleView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
Resources res = getResources();
mCornerMargin = res.getDimension(R.dimen.circle_corner_margin);
mRadiusTarget = res.getDimension(R.dimen.circle_radius_target);
mRadiusDecreaseThreshold = res.getDimension(R.dimen.circle_radius_decrease_threshold);
mShortAnimTime = res.getInteger(android.R.integer.config_shortAnimTime);
mMediumAnimTime = res.getInteger(android.R.integer.config_mediumAnimTime);
mDrawableLeftTopCorner = new CornerIconDrawable(Config.KEY_CORNER_ACTION_LEFT_TOP);
mDrawableRightTopCorner = new CornerIconDrawable(Config.KEY_CORNER_ACTION_RIGHT_TOP);
mDrawableLeftBottomCorner = new CornerIconDrawable(Config.KEY_CORNER_ACTION_LEFT_BOTTOM);
mDrawableRightBottomCorner = new CornerIconDrawable(Config.KEY_CORNER_ACTION_RIGHT_BOTTOM);
mPaint = new Paint();
mPaint.setAntiAlias(true);
initInverseColorFilter();
setRadius(0);
}
private void initInverseColorFilter() {
final float v = -1;
final float[] matrix = {
v, 0, 0, 0, 0,
0, v, 0, 0, 0,
0, 0, v, 0, 0,
0, 0, 0, 1, 0,
};
mInverseColorFilter = new ColorMatrixColorFilter(matrix);
}
public void setSupervisor(Supervisor supervisor) {
mSupervisor = supervisor;
}
public void setCallback(Callback callback) {
mCallback = callback;
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
// Start tracking the corners' icons.
Context context = getContext();
mDrawableLeftTopCorner.start(context);
mDrawableRightTopCorner.start(context);
mDrawableLeftBottomCorner.start(context);
mDrawableRightBottomCorner.start(context);
}
@Override
protected void onDraw(Canvas canvas) {
final float ratio = calculateRatio();
// Draw all corners
drawCornerIcon(canvas, mDrawableLeftTopCorner, 0, 0 /* left top */);
drawCornerIcon(canvas, mDrawableRightTopCorner, 1, 0 /* right top */);
drawCornerIcon(canvas, mDrawableLeftBottomCorner, 0, 1 /* left bottom */);
drawCornerIcon(canvas, mDrawableRightBottomCorner, 1, 1 /* right bottom */);
// Darkening background
int alpha = (int) (mDarkening * 255);
alpha += (int) ((255 - alpha) * ratio * 0.7f); // Change alpha dynamically
canvas.drawColor(Color.argb(alpha,
Color.red(mOuterColor),
Color.green(mOuterColor),
Color.blue(mOuterColor)));
// Draw unlock circle
mPaint.setColor(mInnerColor);
mPaint.setAlpha((int) (255 * Math.pow(ratio, 1f / 3f)));
canvas.drawCircle(mPoint[0], mPoint[1], mRadiusDrawn, mPaint);
if (ratio >= 0.5f) {
// Draw unlock icon at the center of circle
float scale = 0.5f + 0.5f * ratio;
canvas.save();
canvas.translate(
mPoint[0] - mDrawable.getMinimumWidth() / 2 * scale,
mPoint[1] - mDrawable.getMinimumHeight() / 2 * scale);
canvas.scale(scale, scale);
mDrawable.draw(canvas);
canvas.restore();
}
}
private void drawCornerIcon(@NonNull Canvas canvas, @NonNull Drawable drawable, int xm, int ym) {
int width = getMeasuredWidth() - drawable.getBounds().width();
int height = getMeasuredHeight() - drawable.getBounds().height();
float margin = (1 - 2 * xm) * mCornerMargin;
// Draw
canvas.save();
canvas.translate(xm * width + margin, ym * height + margin);
drawable.draw(canvas);
canvas.restore();
}
@Override
protected void onDetachedFromWindow() {
cancelAndClearAnimator();
mHandler.removeCallbacksAndMessages(null);
mDrawableCache.clear();
mDrawableLeftTopCorner.stop();
mDrawableRightTopCorner.stop();
mDrawableLeftBottomCorner.stop();
mDrawableRightBottomCorner.stop();
super.onDetachedFromWindow();
}
private void setInnerColor(int color, boolean needsColorReset) {
if (mInnerColor == (mInnerColor = color) && !needsColorReset) return;
// Inverse the drawable if needed
boolean isBright = ColorUtils.calculateLuminance(color) > 0.5;
mDrawable.setColorFilter(isBright ? mInverseColorFilter : null);
}
private void setOuterColor(int color) {
mOuterColor = color;
}
/**
* Updates the icon in center of the circle, to the once corresponding
* with the current action.
*
* @see CornerHelper
*/
private boolean updateIcon() {
final int res = CornerHelper.getIconResource(mCornerActionId);
if (res == mDrawableResourceId) return false; // No need to update
mDrawableResourceId = res;
label:
{
// Try to get from the cache.
final CharSequence key = Integer.toString(res);
mDrawable = mDrawableCache.get(key);
if (mDrawable != null) {
if (DEBUG) Log.d(TAG, "Got an icon<" + key + "> from the cache.");
break label;
}
// Load from resources.
mDrawable = ResUtils.getDrawable(getContext(), res);
assert mDrawable != null;
mDrawable.setBounds(0, 0,
mDrawable.getIntrinsicWidth(),
mDrawable.getIntrinsicHeight());
mDrawable = mDrawable.mutate(); // don't affect the original drawable
mDrawableCache.put(key, mDrawable);
}
// Update alpha
float ratio = calculateRatio();
mDrawable.setAlpha((int) (255 * Math.pow(ratio, 3)));
return true;
}
public boolean sendTouchEvent(@NonNull MotionEvent event) {
final int action = event.getActionMasked();
// If current circle is canceled then
// ignore all actions except of touch down (to reset state.)
if (mCanceled && action != MotionEvent.ACTION_DOWN) return false;
// Cancel the current circle on two-or-more-fingers touch.
if (event.getPointerCount() > 1) {
cancelCircle();
return false;
}
final float x = event.getX();
final float y = event.getY();
switch (action) {
case MotionEvent.ACTION_DOWN:
clearAnimation();
Config config = Config.getInstance();
// Corner actions
int width = getWidth();
int height = getHeight();
int radius = Math.min(width, height) / 3;
if (MathUtils.isInCircle(x, y, 0, 0, radius)) { // Top left
mCornerActionId = config.getCornerActionLeftTop();
} else if (MathUtils.isInCircle(x, y, -width, 0, radius)) { // Top right
mCornerActionId = config.getCornerActionRightTop();
} else if (MathUtils.isInCircle(x, y, 0, -height, radius)) { // Bottom left
mCornerActionId = config.getCornerActionLeftBottom();
} else if (MathUtils.isInCircle(x, y, -width, -height, radius)) { // Bottom right
mCornerActionId = config.getCornerActionRightBottom();
} else {
// The default action is unlocking.
mCornerActionId = Config.CORNER_UNLOCK;
}
// Update colors and icon drawable.
boolean needsColorReset = updateIcon();
setInnerColor(getColor(config.getCircleInnerColor()), needsColorReset);
setOuterColor(getColor(config.getCircleOuterColor()));
// Initialize circle
mRadiusTargetAimed = false;
mRadiusMaxPeak = 0;
mPoint[0] = x;
mPoint[1] = y;
mCanceled = false;
if (mHandler.hasMessages(ACTION_UNLOCK)) {
// Cancel unlocking process.
mHandler.sendEmptyMessage(ACTION_UNLOCK_CANCEL);
}
mHandler.removeCallbacksAndMessages(null);
mHandler.sendEmptyMessageDelayed(MSG_CANCEL, 1000);
mHandler.sendEmptyMessage(ACTION_START);
break;
case MotionEvent.ACTION_MOVE:
setRadius(x, y);
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
if (!mRadiusTargetAimed || action == MotionEvent.ACTION_CANCEL) {
cancelCircle();
break;
}
startUnlock();
break;
}
return true;
}
private void cancelCircle() {
cancelCircle(mSupervisor.isAnimationUnlockEnabled());
}
private void cancelCircle(boolean animate) {
Check.getInstance().isFalse(mCanceled);
mCanceled = true;
mHandler.removeCallbacksAndMessages(null);
mHandler.sendEmptyMessage(ACTION_CANCELED);
if (animate) {
startAnimatorBy(mRadius, 0f, mMediumAnimTime);
} else {
setRadius(0f);
}
}
private void startUnlock() {
startUnlock(mSupervisor.isAnimationUnlockEnabled());
}
private void startUnlock(boolean animate) {
if (animate) {
// Calculate longest distance between center of
// the circle and view's corners.
float distance = 0f;
int[] corners = new int[]{
0, 0, // top left
0, getHeight(), // bottom left
getWidth(), getHeight(), // bottom right
getWidth(), 0 // top right
};
for (int i = 0; i < corners.length; i += 2) {
double c = Math.hypot(
mPoint[0] - corners[i],
mPoint[1] - corners[i + 1]);
if (c > distance) distance = (float) c;
}
distance = (float) (Math.pow(distance / 50f, 2) * 50f);
startAnimatorBy(mRadius, distance, mShortAnimTime);
}
final int delayUnlock = animate ? mShortAnimTime - 10 : 0;
mHandler.removeCallbacksAndMessages(null);
mHandler.sendEmptyMessage(ACTION_UNLOCK_START);
mHandler.sendEmptyMessageDelayed(ACTION_UNLOCK, delayUnlock);
}
private void startAnimatorBy(float from, float to, int duration) {
cancelAndClearAnimator();
// Animate the circle
mAnimator = ObjectAnimator.ofFloat(this, RADIUS_PROPERTY, from, to);
mAnimator.setInterpolator(new FastOutLinearInInterpolator());
mAnimator.setDuration(duration);
mAnimator.start();
}
private void cancelAndClearAnimator() {
if (mAnimator != null) {
mAnimator.cancel();
mAnimator = null;
}
}
private float calculateRatio() {
return Math.min(mRadius / mRadiusTarget, 1f);
}
private void setRadius(float x, float y) {
double radius = Math.hypot(x - mPoint[0], y - mPoint[1]);
setRadius((float) radius);
}
/**
* Sets the radius of fake circle.
*
* @param radius radius to set
*/
private void setRadius(float radius) {
mRadius = radius;
if (!mCanceled) {
// Save maximum radius for detecting
// decreasing of the circle's size.
if (mRadius > mRadiusMaxPeak) {
mRadiusMaxPeak = mRadius;
} else if (mRadiusMaxPeak - mRadius > mRadiusDecreaseThreshold) {
cancelCircle();
return; // Cancelling circle will recall #setRadius
}
boolean aimed = mRadius >= mRadiusTarget;
if (mRadiusTargetAimed != aimed) {
mRadiusTargetAimed = aimed;
// Vibrate if the user is interacting with the device.
if (isInTouchMode()) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
}
}
final float ratio = calculateRatio();
int alpha;
// Update unlock icon's transparency.
if (mDrawable != null) {
alpha = (int) (255 * Math.pow(ratio, 3));
mDrawable.setAlpha(alpha);
}
// Update corners' icons transparency.
alpha = (int) (50f * Math.pow(1f - ratio, 0.3f));
mDrawableLeftTopCorner.setAlpha(alpha);
mDrawableRightTopCorner.setAlpha(alpha);
mDrawableLeftBottomCorner.setAlpha(alpha);
mDrawableRightBottomCorner.setAlpha(alpha);
// Update the size of the unlock circle.
radius = (float) Math.sqrt(mRadius / 50f) * 50f;
setRadiusDrawn(radius);
}
private void setRadiusDrawn(float radius) {
mRadiusDrawn = radius;
postInvalidateOnAnimation();
}
private static class H extends WeakHandler<CircleView> {
public H(@NonNull CircleView cv) {
super(cv);
}
@Override
protected void onHandleMassage(@NonNull CircleView cv, Message msg) {
switch (msg.what) {
case MSG_CANCEL:
cv.cancelCircle();
break;
case ACTION_START:
case ACTION_UNLOCK:
case ACTION_UNLOCK_START:
case ACTION_UNLOCK_CANCEL:
case ACTION_CANCELED:
if (cv.mCallback != null) {
final float ratio = cv.calculateRatio();
final int actionId = cv.mCornerActionId;
cv.mCallback.onCircleEvent(cv.mRadius, ratio, msg.what, actionId);
}
break;
default:
throw new IllegalArgumentException();
}
}
}
}