Skip to content

Instantly share code, notes, and snippets.

@jmarkovic
Last active December 14, 2016 13:28
Show Gist options
  • Save jmarkovic/a373680c0ad379f143fb18757556c3ca to your computer and use it in GitHub Desktop.
Save jmarkovic/a373680c0ad379f143fb18757556c3ca to your computer and use it in GitHub Desktop.
Animated dots on the screen useful for displaying a PIN input.
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="PinDisplayView">
<attr name="enteredColor" format="color"/>
<attr name="emptyColor" format="color"/>
<attr name="dotRadius" format="dimension"/>
<attr name="dotSpacing" format="dimension"/>
<attr name="dotCount" format="integer"/>
</declare-styleable>
</resources>

Setting up the view should be very easy. First, copy the files PinDisplayView.java and attrs.xml to your project. If you don't have one already, copy ViewUtil.java file as well. It is important that you have the ViewUtil#resolveMeasureSpec method available somewhere. Fix all the imports and verify the app can compile.

Example usage

Add the view to the layout. There's nothing specific you need to do, the view works as is. If you want to, you can modify some attributes as in the example below:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        android:padding="@dimen/default_padding">
    
<example.app.PinDisplayView
    android:id="@+id/pin_view"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_gravity="center_horizontal"
    android:layout_marginTop="18dp"
    android:layout_marginBottom="26dp"
    app:dotCount="4"
    app:dotRadius="6dp"
    app:dotSpacing="22dp"
    app:enteredColor="@color/orange"
    app:emptyColor="@color/gray"/>
    
</FrameLayout>

Once you have an instance of PinDisplayView, you can interact with it using PinDisplayView#addActive, PinDisplayView#removeActive, PinDisplayView#shakeReset and PinDisplayView#reset methods.

addActive

Method PinDisplayView#addActive turns the first inactive dot from the left to an active dot. The dot is animated from a small circle to a larger circle. It continues to enlarge and fade out after the max size has been reached.

removeActive

Method PinDisplayView#removeActive turns the first active dot from the right to an inactive dot.

shakeReset

Method PinDisplayView#shakeReset resets the state of all dots to inactive state. The whole view is animated with a shake animation. On devices that support it, the device vibrates in the duration of the animation.

reset

Method PinDisplayView#reset resets the state of all dots to inactive state, but without the animation and the vibration. This is useful when the state of the view needs to be reset without the user interaction.

/**
* Displays animated dots as pin entries.
*/
public class PinDisplayView extends View {
private static final int DEFAULT_DOT_COUNT = 4;
private static final int DEFAULT_DOT_RADIUS = 6;
private static final int DEFAULT_DOT_SPACING = 11;
private static final int HEIGHT_MODIFIER = 6;
private static final int VIBRATOR_DURATION = 500;
private static final int ANIMATION_SEGMENT_DURATION = 50;
private static final int THREE = 3;
private static final float TWO_THIRDS = (float) 2 / (float) THREE;
private Vibrator vibrator;
private Paint darkPaint;
private Paint lightPaint;
private float dotRadius, smallDotRadius;
private float dotSpacing;
private int dotCount = DEFAULT_DOT_COUNT;
private int desiredWidth;
private Dot[] dots;
public PinDisplayView(Context context) {
super(context);
init(context, null, 0);
}
public PinDisplayView(Context context, AttributeSet attrs) {
super(context, attrs);
init(context, attrs, 0);
}
public PinDisplayView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context, attrs, defStyleAttr);
}
private void init(Context context, AttributeSet attrs, int defStyleAttr) {
this.darkPaint = new Paint();
this.lightPaint = new Paint();
final TypedValue colorControlActivated = new TypedValue();
getContext().getTheme().resolveAttribute(R.attr.colorControlActivated, colorControlActivated, true);
final int defaultColorActivated = colorControlActivated.data;
final TypedValue colorControlNormal = new TypedValue();
getContext().getTheme().resolveAttribute(R.attr.colorControlNormal, colorControlNormal, true);
final int defaultColorNormal = colorControlNormal.data;
this.dotRadius = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,
DEFAULT_DOT_RADIUS, getResources().getDisplayMetrics());
this.smallDotRadius = dotRadius * TWO_THIRDS;
this.dotSpacing = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,
DEFAULT_DOT_SPACING, getResources().getDisplayMetrics());
int attrColorActivated = 0, attrColorNormal = 0;
if (attrs != null) {
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.PinDisplayView, defStyleAttr, 0);
attrColorActivated = a.getColor(R.styleable.PinDisplayView_enteredColor, defaultColorActivated);
attrColorNormal = a.getColor(R.styleable.PinDisplayView_emptyColor, defaultColorNormal);
this.dotRadius = a.getDimension(R.styleable.PinDisplayView_dotRadius, this.dotRadius);
this.smallDotRadius = dotRadius * TWO_THIRDS;
this.dotSpacing = a.getDimension(R.styleable.PinDisplayView_dotSpacing, this.dotSpacing);
this.dotCount = a.getInt(R.styleable.PinDisplayView_dotCount, DEFAULT_DOT_COUNT);
a.recycle();
}
darkPaint.setColor(attrColorActivated != 0 ? attrColorActivated : defaultColorActivated);
darkPaint.setAntiAlias(true);
lightPaint.setColor(attrColorNormal != 0 ? attrColorNormal : defaultColorNormal);
lightPaint.setAntiAlias(true);
dots = new Dot[dotCount];
for (int i = 0; i < dotCount; i++) {
dots[i] = new Dot(0, 0);
}
if (!isInEditMode()) {
this.vibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE);
}
}
@Override
protected void onDraw(Canvas canvas) {
boolean shouldRedraw = false;
for (Dot dot : dots) {
final float useRadius = dot.enabled ? dotRadius : smallDotRadius;
final Paint usePaint = dot.enabled ? darkPaint : lightPaint;
if (dot.animate) {
if (dot.transientRadius == 0) {
dot.transientRadius = (int) (dotRadius * 2);
}
canvas.drawCircle(dot.x, dot.y, dot.transientRadius, usePaint);
if (dot.transientRadius <= useRadius) {
dot.transientRadius = 0;
dot.animate = false;
} else {
shouldRedraw = true;
dot.transientRadius -= 2;
}
}
canvas.drawCircle(dot.x, dot.y, useRadius, usePaint);
if (shouldRedraw) {
invalidate();
}
}
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
this.desiredWidth = (int) ((dotRadius * 2 + dotSpacing) * dotCount);
final int desiredHeight = (int) (dotRadius * HEIGHT_MODIFIER);
final int[] resultingWidthAndHeight = new int[2];
ViewUtil.resolveMeasureSpec(widthMeasureSpec, heightMeasureSpec,
desiredWidth, desiredHeight, resultingWidthAndHeight);
//MUST CALL THIS
setMeasuredDimension(resultingWidthAndHeight[0], resultingWidthAndHeight[1]);
}
/**
* Creates {@link Dot} positioning in regards to supplied
* {@code width} and {@code height}. Dots will always tend to gravitate
* to the middle of the view.
*/
@Override
protected void onSizeChanged(int width, int height, int oldw, int oldh) {
if (width > 0 && height > 0) {
final int incrementalStep = (int) (dotRadius * 2 + dotSpacing);
final int y = height / 2;
final int startOffset = (int) ((width - desiredWidth + dotRadius + smallDotRadius + dotSpacing) / 2);
for (int i = 0; i < dotCount; i++) {
final int x = incrementalStep * i + startOffset;
final Dot dot = new Dot(x, y);
dots[i] = dot;
}
}
}
/**
* Activates first deactivated dot from the left.
* This Dot enters an animation that transitions the dot
* to the activated state.
*/
public void addActive() {
for (Dot dot : dots) {
if (!dot.enabled) {
dot.enabled = true;
dot.animate = true;
break;
}
}
invalidate();
}
/**
* Deactivates the first activated dot from the right.
* This dot enters an animation that transitions the dot
* to the deactivated state.
*/
public void removeActive() {
for (int i = dots.length - 1; i >= 0; i --) {
if (dots[i].enabled) {
dots[i].enabled = false;
dots[i].animate = true;
break;
}
}
invalidate();
}
/**
* Resets all dots to the deactivated state. All dots
* enter a deactivate and shake animation. Devices that
* support vibrations will be vibrated at this point.
*/
public void shakeReset() {
for (Dot dot : dots) {
dot.animate = true;
dot.enabled = false;
}
if (vibrator.hasVibrator()) {
vibrator.vibrate(VIBRATOR_DURATION);
}
invalidate();
AnimatorSet animatorSet = new AnimatorSet();
animatorSet.playSequentially(
ObjectAnimator.ofFloat(this, "translationX", dotRadius).setDuration(ANIMATION_SEGMENT_DURATION),
ObjectAnimator.ofFloat(this, "translationX", - dotRadius * 2).setDuration(ANIMATION_SEGMENT_DURATION),
ObjectAnimator.ofFloat(this, "translationX", dotRadius * 2).setDuration(ANIMATION_SEGMENT_DURATION),
ObjectAnimator.ofFloat(this, "translationX", - dotRadius * 2).setDuration(ANIMATION_SEGMENT_DURATION),
ObjectAnimator.ofFloat(this, "translationX", dotRadius).setDuration(ANIMATION_SEGMENT_DURATION)
);
animatorSet.start();
}
/**
* Does the same thing as {@link #shakeReset()}
* but does not shake or animate the view.
*/
public void reset() {
for (Dot dot : dots) {
dot.enabled = false;
}
invalidate();
}
static class Dot {
final int x;
final int y;
boolean enabled;
boolean animate;
int transientRadius;
Dot(int x, int y) {
this.x = x;
this.y = y;
}
}
}
public final class ViewUtil {
private ViewUtil() {
throw new UnsupportedOperationException(this.getClass().getSimpleName() + " cannot be instantiated!");
}
public static void resolveMeasureSpec(final int widthMeasureSpec, final int heightMeasureSpec,
final int desiredWidth, final int desiredHeight, @Size(2) int[] resultSize) {
int widthMode = View.MeasureSpec.getMode(widthMeasureSpec);
int widthSize = View.MeasureSpec.getSize(widthMeasureSpec);
int heightMode = View.MeasureSpec.getMode(heightMeasureSpec);
int heightSize = View.MeasureSpec.getSize(heightMeasureSpec);
int width;
int height;
//Measure Width
if (View.MeasureSpec.EXACTLY == widthMode) {
//Must be this size
width = widthSize;
} else if (View.MeasureSpec.AT_MOST == widthMode) {
//Can't be bigger than...
width = Math.min(desiredWidth, widthSize);
} else {
//Be whatever you want
width = desiredWidth;
}
//Measure Height
if (heightMode == View.MeasureSpec.EXACTLY) {
//Must be this size
height = heightSize;
} else if (heightMode == View.MeasureSpec.AT_MOST) {
//Can't be bigger than...
height = Math.min(desiredHeight, heightSize);
} else {
//Be whatever you want
height = desiredHeight;
}
resultSize[0] = width;
resultSize[1] = height;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment