Building a PinEntryEditText in Android

This is the first in a series of articles in which I create custom views to demonstrate how easy it is in android to create functionality which may not exist out of the box.

Ali Muzaffar
Feb 21, 2016 · 9 min read

Its worth pointing out that if you need to create functionality to enter pins in your app, you can probably get away with using a project like PinEntryView by Philio. For my purposes, I found that there was some issue with accessibility features in using Philio’s solution. To be precise, when texts on a device were scaled using accessibility settings, the text scaled unpredictably. Philio provides a crap ton of options to configure that widget in almost every conceivable way. If it works for you then great! If you want to create your own, please read on.

The high level approach

Its also worth pointing out that in Philios solution, he uses a ViewGroup that contains a hidden EditText for typing and X number of custom views depending on how many pins you code can be. In my solution, we are going to extends a EditText to create a 4 character PinEntryEditText and our solution while a little configurable, will be very “built for purpose”. While I won’t go into the details, you can probably create more configuration options to make the view more general purpose.

This is what the final widget will look like:

Requirements

  • character pins of any length (digit, alphanumeric doesn't really matter, you’ll see why soon).
  • The pins View will fill up the space available to the widget (much like the EditText does). The first and last character spaces will be against the left and right edges and the middle character spaces will be evenly spaced out.

Why extend EditText?

We extend EditText because this gives us a lot for free. Such as:

  • Focus on the field and bringing up the keyboard.
  • Text size and color controls.
  • Character and input length controls and listeners.

What do we have to do?

We will simply override the onDraw method and remove the super() call, this should prevent most of the EditText related features from not being drawn. We will then draw the lines or boxes or whatever to represent the placeholders for each digit and then read the characters input and draw each character in it’s correct position.

We will also have to do the following:

  • Disable Copy/Paste
  • Hide the cursor.
  • Disable tapping to reposition the cursor.

Step 1: Extend EditText

This is rather straight forward and very mundane.

public class PinEntryEditText extends EditText {

public PinEntryEditText(Context context) {
super(context);
}

public PinEntryEditText(Context context, AttributeSet attrs) {
super(context, attrs);
init(context, attrs);
}

public PinEntryEditText(Context context, AttributeSet attrs,
int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context, attrs);
}

@TargetApi(Build.VERSION_CODES.LOLLIPOP)
public PinEntryEditText(Context context, AttributeSet attrs,
int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
init(context, attrs);
}

private void init(Context context, AttributeSet attrs) {

}

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
}
}

Step 2: Draw your PinEntryEditText

This step can be further thought of in 2 steps:

  1. Prevent drawing of the default EditText.
  2. Draw your PinEntryEditText.

Before we start, create a layout file and add our PinEntryEditText to it, so we can see the changes we make to the EditText.

activity_main.xml:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:clipChildren="false"
android:clipToPadding="false"
tools:context="com.alimuzaffar.customwidgets.MainActivity">

<com.alimuzaffar.customwidgets.PinEntryEditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:cursorVisible="false"
android:digits="1234567890"
android:inputType="number"
android:maxLength="4"
android:textIsSelectable="false"
android:textSize="20sp" />

</LinearLayout>

Note the attributes we are using, all of them belong to EditText by default and contribute to making the PinEntryEditText look more and more like a pin entry screen.

Step 2a: Preview drawing of default EditText

We can accomplish this by removing anything set on the background and by not calling onDraw.

private void init(Context context, AttributeSet attrs) {
setBackgroundResource(0);
}

@Override
protected void onDraw(Canvas canvas) {
//super.onDraw(canvas);
}

Step 2b: Draw your PinEntryEditText

We can choose to draw lines, circles, squares here, whatever. There is a bit of a trick to this though and that is to calculate the spaces between the lines and not just the lines themselves. If you know what the space between the lines, you can calculate the start and end point of each line. You can choose to have a fixed size for the spaces or you can make them equal to the lines.

If you choose to make the spaces as wide as the lines, then the calculation for the length of the line/space is straight forward:

availableWidth / (numberOfLines * 2 - 1)

This works because if you have 4 lines, you’ll have 3 spaces. The first line will start at the beginning of the available space and the last line will end at the end of the available space. The other lines should be spaced out evenly.

If you choose to make the spaces a fixed width, then you have to calculate just how wide each line will be, again, the formula for this is straight forward:

(availableWidth -(spaceWidth * (numberOfLines - 1)))/ numberOfLines

For our example, lets go ahead and assume that if space < 0 that we want everything evenly spaced out. Our code will look like this:

float mSpace = 24; //24 dp by default
float mCharSize = 0;
float mNumChars = 4;

private void init(Context context, AttributeSet attrs) {
setBackgroundResource(0);

float multi = context.getResources().getDisplayMetrics().density;
mSpace = multi * mSpace; //convert to pixels for our density
}


@Override
protected void onDraw(Canvas canvas) {
//super.onDraw(canvas);
int availableWidth =
getWidth() - getPaddingRight() - getPaddingLeft();
if (mSpace < 0) {
mCharSize = (availableWidth / (mNumChars * 2 - 1));
} else {
mCharSize =
(availableWidth - (mSpace * (mNumChars - 1))) / mNumChars;
}

int startX = getPaddingLeft();
int bottom = getHeight() - getPaddingBottom();

for (int i=0; i< mNumChars; i++) {
canvas.drawLine(
startX, bottom, startX + mCharSize, bottom, getPaint());
if (mSpace < 0) {
startX += mCharSize * 2;
} else {
startX += mCharSize + mSpace;
}
}
}

If you run this code, or just look at it in the editor, it will look like this:

Fixed size spaces

If you change mSpace to -1, you’ll see something like this:

Auto size spaces

I encourage you to play around with it by adding padding to the layout and so on. The views should remain correctly spaced.

Note: So far, we have simply used the paint object available with the EditText. We can define our own to control the colors of the line and make them different from the text.

Step 3: Draw our text

This is straight forward because all the text attributes are being controlled by the EditText and we simply have to use the paint object in the EditText. This is a little complex, because we have to calculate the size of each character and draw it centered on the line. We may also want to add some padding between the characters and the line.

We are going to tie the number of characters to draw to the maxLength attribute. and then by default make the text hover 8dp above the drawn lines.

float mSpace = 24; //24 dp by default
float mCharSize = 0;
float mNumChars = 4;
float mLineSpacing = 8; //8dp by default

You could tie this value to lineSpacingExtra attribute if you wish and the space between the drawn lines to drawablePadding. The code for this will be similar to how I have done this for maxLength.

If you look at the code in the editor, and set the android:text attribute to some value, it will look something like this:

Draw the text to on the lines

Step 4: Disable copy/paste and click to move cursor

We do this by intercepting onClickListener and by setting a CustomSelectionActionModeCallback. OnClick we want to move the cursor to the end of the text and the ActionMode.Callback will disable all behaviour.

private OnClickListener mClickListener;

private void init(Context context, AttributeSet attrs) {
setBackgroundResource(0);

float multi =
context.getResources().getDisplayMetrics().density;
mSpace = multi * mSpace; //convert to pixels for our density
mLineSpacing = multi * mLineSpacing; //convert to pixels
mMaxLength = attrs.getAttributeIntValue(
XML_NAMESPACE_ANDROID, "maxLength", 4);
mNumChars = mMaxLength;

//Disable copy paste
super.setCustomSelectionActionModeCallback(
new ActionMode.Callback() {
public boolean onPrepareActionMode(ActionMode mode,
Menu menu) {
return false;
}

public void onDestroyActionMode(ActionMode mode) {
}

public boolean onCreateActionMode(ActionMode mode,
Menu menu) {
return false;
}

public boolean onActionItemClicked(ActionMode mode,
MenuItem item) {
return false;
}
});
//When tapped, move cursor to end of the text
super.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
setSelection(getText().length());
if (mClickListener != null) {
mClickListener.onClick(v);
}
}
});

}

@Override
public void setOnClickListener(OnClickListener l) {
mClickListener = l;
}

@Override
public void setCustomSelectionActionModeCallback(ActionMode.Callback actionModeCallback) {
throw new RuntimeException("setCustomSelectionActionModeCallback() not supported.");
}

Step 5: Run our app

We can now run our app and the widget should be fully functional. You can add a TextWatcher to our PinEntryEditText in order to react to the characters input.

This is what our test code looks like:

public class MainActivity extends AppCompatActivity {

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

final PinEntryEditText txtPinEntry =
(PinEntryEditText) findViewById(R.id.txt_pin_entry);
txtPinEntry.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) {}

@Override
public void afterTextChanged(Editable s) {
if (s.toString().equals("1234")) {
Toast.makeText(MainActivity.this,
"Success", Toast.LENGTH_SHORT).show();
} else if (s.length() == "1234".length()) {
Toast.makeText(MainActivity.this,
"Incorrect", Toast.LENGTH_SHORT).show();
txtPinEntry.setText(null);
}
}
});
}
}

Dressing up the PinEntryEditText

The problem with the code above is that there is no way to tell when the PinEntryEditText is focused or when it’s unfocused.

We can address this by creating a ColorStateList and setting the color of the lines depending on the state of the PinEntryEditText.

private float mLineStroke = 1; //1dp by default
private Paint mLinesPaint;
int[][] mStates = new int[][]{
new int[]{android.R.attr.state_selected}, // selected
new int[]{android.R.attr.state_focused}, // focused
new int[]{-android.R.attr.state_focused}, // unfocused
};

int[] mColors = new int[]{
Color.GREEN,
Color.BLACK,
Color.GRAY
};

ColorStateList mColorStates = new ColorStateList(mStates, mColors);

private int getColorForState(int... states) {
return mColorStates.getColorForState(states, Color.GRAY);
}

Then simply in onDraw, we want to use mLinePaint to draw the lines instead of the Paint object returned by getPaint(). We also want to call updateColorsForLines(boolean) before drawing each line to make sure the correct color is drawn.

@Override
protected void onDraw(Canvas canvas) {
...
for (int i = 0; i < mNumChars; i++) {
updateColorForLines(i == textLength);
canvas.drawLine(startX, bottom, startX + mCharSize,
bottom, mLinesPaint);
...
}
}

This works great, however, it’s not picking colors from our theme, just using the default hard coded colors. We can use colors from our theme as follows:

TypedValue outValue = new TypedValue();
context.getTheme().resolveAttribute(R.attr.colorControlActivated,
outValue, true);
final int colorActivated = outValue.data;
mColors[0] = colorActivated;

context.getTheme().resolveAttribute(R.attr.colorPrimaryDark,
outValue, true);
final int colorDark = outValue.data;
mColors[1] = colorDark;

context.getTheme().resolveAttribute(R.attr.colorControlHighlight,
outValue, true);
final int colorHighlight = outValue.data;
mColors[2] = colorHighlight;

The video below shows the final result.

Finally

If you have read through the code, you can probably see how easy it would be to add a mask (in case you don.t want to show the entered text). Simply instead of drawing the text, draw an astrix, circle or really anything else you want. You can see the full code at this gist.

Next time, I’ll walk you through making a Pattern lock view.

Also, in order to build great Android apps, read more of my articles.


Yay! you made it to the end! We should hang out! feel free to follow me on Medium, LinkedIn, Google+ or Twitter.

Ali Muzaffar

Written by

A software engineer, an Android, and a ray of hope for your darkest code. Residing in Sydney.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade