package com.venmo.android.pin.view;
import android.animation.Animator;
import android.animation.Animator.AnimatorListener;
import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.animation.ValueAnimator.AnimatorUpdateListener;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.ShapeDrawable;
import android.graphics.drawable.shapes.OvalShape;
import android.graphics.drawable.shapes.Shape;
import android.os.Bundle;
import android.os.Parcelable;
import android.text.Editable;
import android.text.InputFilter;
import android.text.InputFilter.LengthFilter;
import android.text.TextWatcher;
import android.text.method.DigitsKeyListener;
import android.util.AttributeSet;
import android.util.Pair;
import android.view.animation.CycleInterpolator;
import android.view.animation.OvershootInterpolator;
import android.widget.TextView;
import com.venmo.android.pin.R;
import com.venmo.android.pin.util.VibrationHelper;
public class PinputView extends TextView {
private static final String TAG = PinputView.class.getSimpleName();
private static final String KEY_SAVED_INSTANCE_STATE = "com.venmo.pin.pinputview.state";
private static final String KEY_SAVED_STATE_PIN = "com.venmo.pin.pinputview.savedPin";
public static final int VIBRATE_LENGTH_DEFAULT = 300;
private int mCharPadding;
private Pair<Drawable, Drawable>[] mDrawables;
private int mPinLen;
private OnCommitListener mListener;
private final int mAnimDuration = 150;
private Animator mErrorAnimator;
private boolean mVibrateOnError = true;
private int mErrorVibrationLen = VIBRATE_LENGTH_DEFAULT;
public interface OnCommitListener {
public void onPinCommit(PinputView view, String submission);
}
public PinputView(Context context) {
super(context);
init(null, 0);
}
public PinputView(Context context, AttributeSet attrs) {
super(context, attrs);
init(attrs, 0);
}
public PinputView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
init(attrs, defStyle);
}
private void init(AttributeSet attrs, int defStyle) {
// @formatter:off
final TypedArray a = getContext().obtainStyledAttributes(attrs, R.styleable.PinputView, defStyle, 0);
// @formatter:on
setFocusableInTouchMode(false);
setKeyListener(DigitsKeyListener.getInstance(false, false));
mPinLen = a.getInt(R.styleable.PinputView_pinputview_len, 4);
mCharPadding = (int) a.getDimension(R.styleable.PinputView_pinputview_characterPadding,
getResources().getDimension(R.dimen.pinputview_default_char_padding));
int foregroundColor = a.getColor(R.styleable.PinputView_pinputview_foregroundColor, Color.BLUE);
int backgroundColor = a.getColor(R.styleable.PinputView_pinputview_backgroundColor, Color.GRAY);
a.recycle();
initDrawables(foregroundColor, backgroundColor);
initFilters();
initializeAnimator();
}
private void initializeAnimator() {
post(new Runnable() {
@Override
public void run() {
float x = getX();
mErrorAnimator =
ObjectAnimator.ofFloat(PinputView.this, "x", x, x + getWidth() / 20);
mErrorAnimator.setInterpolator(new CycleInterpolator(3));
mErrorAnimator.setDuration(mErrorVibrationLen);
mErrorAnimator.addListener(new AnimatorListener() {
@Override
public void onAnimationStart(Animator animator) {
vibrateOnError();
}
@Override
public void onAnimationEnd(Animator animator) {
getText().clear();
}
@Override
public void onAnimationCancel(Animator animator) {
}
@Override
public void onAnimationRepeat(Animator animator) {
}
});
}
});
}
private void initDrawables(int foregroundColor, int backgroundColor) {
int size = (int) getTextSize();
int left = getPaddingLeft();
int top = getPaddingTop() + mCharPadding;
int right = left + size;
int bottom = top + size;
mDrawables = new Pair[mPinLen];
for (int i = 0; i < mPinLen; i++) {
int charOff = i * size + ((i + 1) * mCharPadding);
Rect bounds = new Rect(left + charOff, top, right + charOff, bottom);
mDrawables[i] = Pair.create(
makeCharShape(size, foregroundColor, bounds),
makeCharShape(size, backgroundColor, bounds));
}
}
private void initFilters() {
addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
if (before == 0 && start < mPinLen) {
animateDrawableIn(mDrawables[start].first, mDrawables[start].second);
}
}
@Override
public void afterTextChanged(final Editable text) {
if (text.length() == mPinLen && mListener != null) {
postDelayed(new Runnable() {
@Override
public void run() {
mListener.onPinCommit(PinputView.this, text.toString());
}
}, mAnimDuration);
}
}
});
InputFilter lenFilter = new LengthFilter(mPinLen);
setFilters(new InputFilter[]{lenFilter});
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
float height = getTextSize() + getPaddingTop() + getPaddingBottom();
int hPadding = mCharPadding * 2;
float width = mPinLen * getTextSize() + getPaddingLeft() + getPaddingRight();
int wPadding = mCharPadding * (mPinLen + 1);
setMeasuredDimension((int) width + wPadding, (int) height + hPadding);
}
public void showErrorAndClear() {
mErrorAnimator.start();
}
public void setVibrateOnError(boolean vibrate) {
setVibrateOnError(vibrate, PinputView.VIBRATE_LENGTH_DEFAULT);
}
public void setVibrateOnError(boolean vibrate, int millis) {
mVibrateOnError = vibrate;
mErrorVibrationLen = millis;
}
public void setListener(OnCommitListener listener) {
mListener = listener;
}
public void setErrorAnimator(Animator errorAnimator) {
mErrorAnimator = errorAnimator;
mErrorAnimator.addListener(new AnimatorListener() {
@Override
public void onAnimationStart(Animator animator) {
vibrateOnError();
}
@Override
public void onAnimationEnd(Animator animator) {
getText().clear();
}
@Override
public void onAnimationCancel(Animator animator) {
}
@Override
public void onAnimationRepeat(Animator animator) {
}
});
}
/**
* @return the pre-defined length of this PIN
* TODO: allow arbitrary length PIN and return -1
*/
public int getPinLen() {
return mPinLen;
}
//TODO: maybe change this to setPin(Drawable) and use clone to make as many as we need
protected Drawable makeCharShape(float size, int color, Rect bounds) {
Shape shape = new OvalShape();
shape.resize(size, size);
ShapeDrawable drawable = new ShapeDrawable(shape);
drawable.getPaint().setColor(color);
drawable.getPaint().setFlags(Paint.ANTI_ALIAS_FLAG);
drawable.setBounds(bounds);
return drawable;
}
protected void animateDrawableIn(Drawable foreground, Drawable background) {
final ShapeDrawable front = (ShapeDrawable) foreground;
final ShapeDrawable back = (ShapeDrawable) background;
float to = ((ShapeDrawable) background).getShape().getWidth();
ValueAnimator animator = ValueAnimator.ofFloat(0, to);
animator.setDuration(mAnimDuration);
animator.setInterpolator(new OvershootInterpolator(3f));
animator.addUpdateListener(new AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float newSize =
animation.getAnimatedFraction() * back.getShape().getWidth();
int alpha = Math.min((int) (255 * animation.getAnimatedFraction()), 255);
int offset = (int) (back.getShape().getWidth() - newSize) / 2;
int left = back.getBounds().left + offset;
int top = back.getBounds().top + offset;
int right = (int) (left + newSize);
int bottom = (int) (top + newSize);
front.getShape().resize(newSize, newSize);
front.setBounds(left, top, right, bottom);
front.setAlpha(alpha);
invalidate(
back.getBounds().left - mCharPadding,
back.getBounds().top - mCharPadding,
right + mCharPadding,
bottom + mCharPadding);
}
});
animator.start();
}
private void vibrateOnError() {
if (mVibrateOnError) {
VibrationHelper.vibrate(getContext(), mErrorVibrationLen);
}
}
@Override
public void onRestoreInstanceState(Parcelable state) {
if (state instanceof Bundle) {
Bundle bundle = (Bundle) state;
setText(bundle.getCharSequence(KEY_SAVED_STATE_PIN));
state = bundle.getParcelable(KEY_SAVED_INSTANCE_STATE);
}
super.onRestoreInstanceState(state);
}
@Override
public Parcelable onSaveInstanceState() {
Bundle bundle = new Bundle();
bundle.putParcelable(KEY_SAVED_INSTANCE_STATE, super.onSaveInstanceState());
bundle.putCharSequence(KEY_SAVED_STATE_PIN, getText().toString());
return bundle;
}
@Override
protected void onDraw(Canvas canvas) {
for (int i = 0; i < mPinLen; i++) {
mDrawables[i].second.draw(canvas);
if (i < getText().length()) {
mDrawables[i].first.draw(canvas);
}
}
}
@Override
public boolean onCheckIsTextEditor() {
return true;
}
@Override
public Editable getText() {
return (Editable) super.getText();
}
@Override
public void setText(CharSequence text, BufferType type) {
super.setText(text, BufferType.EDITABLE);
}
}