How to make EditText
accept input in format:
4digit 4digit 4digit 4digit
I tried Custom format edit text input android to acc
I'm adding my solution to the list. As far as I am aware, it has no drawback; you can edit in the middle, delete spacing characters, copy and paste into it etc.
To allow editing to take place anywhere in the string, and to maintain cursor position, the Editable is traversed and all whitespace (if any) are taken out one by one. New whitespace is then added at appropriate positions. This will ensure that the cursor moves along with the changes made to the contents.
import java.util.LinkedList;
import android.text.Editable;
import android.text.TextWatcher;
import android.widget.EditText;
/**
* Formats the watched EditText to groups of characters, with spaces between them.
*/
public class GroupedInputFormatWatcher implements TextWatcher {
private static final char SPACE_CHAR = ' ';
private static final String SPACE_STRING = String.valueOf(SPACE_CHAR);
private static final int GROUPSIZE = 4;
/**
* Breakdown of this regexp:
* ^ - Start of the string
* (\\d{4}\\s)* - A group of four digits, followed by a whitespace, e.g. "1234 ". Zero or more times.
* \\d{0,4} - Up to four (optional) digits.
* (?<!\\s)$ - End of the string, but NOT with a whitespace just before it.
*
* Example of matching strings:
* - "2304 52"
* - "2304"
* - ""
*/
private final String regexp = "^(\\d{4}\\s)*\\d{0,4}(?<!\\s)$";
private boolean isUpdating = false;
private final EditText editText;
public GroupedInputFormatWatcher(EditText editText) {
this.editText = editText;
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void afterTextChanged(Editable s) {
String originalString = s.toString();
// Check if we are already updating, to avoid infinite loop.
// Also check if the string is already in a valid format.
if (isUpdating || originalString.matches(regexp)) {
return;
}
// Set flag to indicate that we are updating the Editable.
isUpdating = true;
// First all whitespaces must be removed. Find the index of all whitespace.
LinkedList<Integer> spaceIndices = new LinkedList <Integer>();
for (int index = originalString.indexOf(SPACE_CHAR); index >= 0; index = originalString.indexOf(SPACE_CHAR, index + 1)) {
spaceIndices.offerLast(index);
}
// Delete the whitespace, starting from the end of the string and working towards the beginning.
Integer spaceIndex = null;
while (!spaceIndices.isEmpty()) {
spaceIndex = spaceIndices.removeLast();
s.delete(spaceIndex, spaceIndex + 1);
}
// Loop through the string again and add whitespaces in the correct positions
for(int i = 0; ((i + 1) * GROUPSIZE + i) < s.length(); i++) {
s.insert((i + 1) * GROUPSIZE + i, SPACE_STRING);
}
// Finally check that the cursor is not placed before a whitespace.
// This will happen if, for example, the user deleted the digit '5' in
// the string: "1234 567".
// If it is, move it back one step; otherwise it will be impossible to delete
// further numbers.
int cursorPos = editText.getSelectionStart();
if (cursorPos > 0 && s.charAt(cursorPos - 1) == SPACE_CHAR) {
editText.setSelection(cursorPos - 1);
}
isUpdating = false;
}
}
1. Copy and paste this class
class EditTextForCards @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = androidx.appcompat.R.attr.editTextStyle
) : AppCompatEditText(context, attrs, defStyleAttr) {
private var mCCPatterns = SparseArray<Pattern>()
private var mSeparator: Separator = Separator.NONE
private var mDrawableGravity: Gravity? = null/*Gravity.END*/
private var isValidCard: Boolean = false
private var mCurrentDrawableResId = Card.UNKNOWN.drawableRes
val textWithoutSeparator
get() = if (mSeparator == Separator.NONE) {
text.toString()
} else {
text.toString().replace(mSeparator.toRegex(), "")
}
val isCardValid: Boolean
get() = textWithoutSeparator.length > 12 && isValidCard
val cardType: Card
get() = Card.from(mCurrentDrawableResId)
enum class Separator(private val stringValue: String) {
NONE(""), SPACES(" "), DASHES("-");
override fun toString() = stringValue
internal fun toRegex() = stringValue.toRegex()
internal val length
get() = stringValue.length
}
enum class Gravity {
START, END, LEFT, RIGHT
}
enum class Card(internal val value: Int, @field:DrawableRes internal val drawableRes: Int) {
VISA(1, R.drawable.ic_visa),
MASTERCARD(2, R.drawable.ic_mastercard),
AMEX(4, R.drawable.amex),
DISCOVER(8, R.drawable.discover),
UNKNOWN(-1, R.drawable.ic_visa);
companion object {
internal fun from(@DrawableRes drawableRes: Int): Card {
for (card in values()) {
if (card.drawableRes == drawableRes) {
return card
}
}
return UNKNOWN
}
}
}
private val textWatcher = object : TextWatcher {
override fun afterTextChanged(s: Editable?) {}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(
text: CharSequence,
start: Int,
lengthBefore: Int,
lengthAfter: Int
) {
val textWithoutSeparator = textWithoutSeparator
var mDrawableResId = 0
for (i in 0 until mCCPatterns.size()) {
val key = mCCPatterns.keyAt(i)
val p = mCCPatterns.get(key)
val m = p.matcher(textWithoutSeparator)
isValidCard = m.find()
if (isValidCard) {
mDrawableResId = key
break
}
}
// if (mDrawableResId != 0 && mDrawableResId != mCurrentDrawableResId) {
// mCurrentDrawableResId = mDrawableResId
// } else if (mDrawableResId == 0) {
// mCurrentDrawableResId = Card.UNKNOWN.drawableRes
// }
// addDrawable()
addSeparators()
}
}
init {
setDisabledCards()
inputType = InputType.TYPE_CLASS_PHONE
setSeparator(Separator.NONE)
// setDrawableGravity(Gravity.END)
attrs?.let { applyAttributes(it) }
addTextChangedListener(textWatcher)
}
private fun applyAttributes(attrs: AttributeSet) {
val a = context.theme.obtainStyledAttributes(
attrs,
R.styleable.EditTextForCards,
0, 0
)
try {
setSeparator(
Separator.values()[a.getInt(
R.styleable.EditTextForCards_separator,
Separator.NONE.ordinal
)]
)
setDisabledCardsInternal(a.getInt(R.styleable.EditTextForCards_disabledCards, 0))
setDrawableGravity(
Gravity.values()[a.getInt(
R.styleable.EditTextForCards_drawableGravity,
Gravity.END.ordinal
)]
)
} finally {
a.recycle()
}
}
private fun addDrawable() {
var currentDrawable = ContextCompat.getDrawable(context, mCurrentDrawableResId)
if (currentDrawable != null && error.isNullOrEmpty()) {
currentDrawable = resize(currentDrawable)
when (mDrawableGravity) {
Gravity.START -> setDrawablesRelative(start = currentDrawable)
Gravity.RIGHT -> setDrawables(right = currentDrawable)
Gravity.LEFT -> setDrawables(left = currentDrawable)
else -> setDrawablesRelative(end = currentDrawable)
}
}
}
private fun addSeparators() {
val text = text.toString()
if (mSeparator != Separator.NONE) {
if (text.length > 4 && !text.matches("(?:[0-9]{4}$mSeparator)+[0-9]{1,4}".toRegex())) {
val sp = StringBuilder()
val caretPosition = selectionEnd
val segments = splitString(text.replace(mSeparator.toRegex(), ""))
for (segment in segments) {
sp.append(segment).append(mSeparator)
}
setText("")
append(sp.delete(sp.length - mSeparator.length, sp.length).toString())
if (caretPosition < text.length)
setSelection(caretPosition)
}
}
}
private fun removeSeparators() {
var text = text.toString()
text = text.replace(" ".toRegex(), "").replace("-".toRegex(), "")
setText("")
append(text)
}
private fun splitString(s: String): Array<String?> {
val arrayLength = ceil(s.length / 4.toDouble()).toInt()
val result = arrayOfNulls<String>(arrayLength)
var j = 0
val lastIndex = result.size - 1
for (i in 0 until lastIndex) {
result[i] = s.substring(j, j + 4)
j += 4
}
result[lastIndex] = s.substring(j)
return result
}
/*@Deprecated("Please use the method that accepts a Separator enum instead.", ReplaceWith("this.setSeparator(Separator.)"))
fun setSeparator(@IntRange(from = 0, to = 2) separator: Int) {
require(!(separator > 2 || separator < 0)) {
"The separator has to be one of the following:" +
"NO_SEPARATOR." +
"SPACES_SEPARATOR." +
"DASHES_SEPARATOR."
}
setSeparator(Separator.values()[separator])
}*/
/**
* Use this method to set the separator style.
* The default separator is [Separator.NONE].
*
* @param separator the style of the separator.
*/
fun setSeparator(separator: Separator) {
mSeparator = separator
if (mSeparator != Separator.NONE) {
filters = arrayOf<InputFilter>(InputFilter.LengthFilter(23))
keyListener = DigitsKeyListener.getInstance("0123456789$mSeparator")
addSeparators()
} else {
filters = arrayOf<InputFilter>(InputFilter.LengthFilter(19))
keyListener = DigitsKeyListener.getInstance("0123456789")
removeSeparators()
}
}
/**
* Use this method to set the location of the card drawable.
* The default gravity is [Gravity.END].
*
* @param gravity the drawable location.
*/
fun setDrawableGravity(gravity: Gravity) {
mDrawableGravity = gravity
addDrawable()
}
private fun setDisabledCardsInternal(disabledCards: Int) {
val cards = ArrayList<Card>()
if (containsFlag(disabledCards, Card.VISA.value)) {
cards.add(Card.VISA)
}
if (containsFlag(disabledCards, Card.MASTERCARD.value)) {
cards.add(Card.MASTERCARD)
}
/*if (containsFlag(disabledCards, Card.AMEX.value)) {
cards.add(Card.AMEX)
}
if (containsFlag(disabledCards, Card.DISCOVER.value)) {
cards.add(Card.DISCOVER)
}*/
setDisabledCards(*cards.toTypedArray())
}
@Deprecated(
"Please use the method that accepts an array of Cards instead.",
ReplaceWith("this.setDisabledCards(cards)")
)
fun setDisabledCards(disabledCards: Int) {
setDisabledCardsInternal(disabledCards)
}
/**
* Use this method to set which cards are disabled.
* By default all supported cards are enabled.
*
* @param cards the cards to be disabled.
*/
fun setDisabledCards(vararg cards: Card) {
var disabledCards = 0
for (card in cards) {
disabledCards = disabledCards or card.value
}
mCCPatterns.clear()
if (!containsFlag(disabledCards, Card.VISA.value)) {
mCCPatterns.put(Card.VISA.drawableRes, Pattern.compile("^4[0-9]{1,12}(?:[0-9]{6})?$"))
}
if (!containsFlag(disabledCards, Card.MASTERCARD.value)) {
mCCPatterns.put(Card.MASTERCARD.drawableRes, Pattern.compile("^5[1-5][0-9]{0,14}$"))
}
/*if (!containsFlag(disabledCards, Card.AMEX.value)) {
mCCPatterns.put(Card.AMEX.drawableRes, Pattern.compile("^3[47][0-9]{0,13}$"))
}
if (!containsFlag(disabledCards, Card.DISCOVER.value)) {
mCCPatterns.put(Card.DISCOVER.drawableRes, Pattern.compile("^6(?:011|5[0-9]{1,2})[0-9]{0,12}$"))
}*/
textWatcher.onTextChanged("", 0, 0, 0)
}
private fun containsFlag(flagSet: Int, flag: Int): Boolean {
return flagSet or flag == flagSet
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
var noDrawablesVisible = true
for (drawable in compoundDrawables) {
if (drawable != null) {
noDrawablesVisible = false
break
}
}
if (noDrawablesVisible) {
addDrawable()
}
}
private fun resize(image: Drawable) =
when (val height = measuredHeight - (paddingTop + paddingBottom)) {
in 1 until image.intrinsicHeight -> {
val bitmap = (image as BitmapDrawable).bitmap
val ratio = image.getIntrinsicWidth().toFloat() / image.intrinsicHeight.toFloat()
val resizedBitmap =
Bitmap.createScaledBitmap(bitmap, (height * ratio).toInt(), height, false)
resizedBitmap.density = Bitmap.DENSITY_NONE
BitmapDrawable(resources, resizedBitmap)
}
in Int.MIN_VALUE..0 -> null
else -> image
}
private fun setDrawablesRelative(
start: Drawable? = null,
top: Drawable? = null,
end: Drawable? = null,
bottom: Drawable? = null
) =
/*TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(this, start, top, end, bottom)*/
TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(this, null, null, null, null)
private fun setDrawables(
left: Drawable? = null,
top: Drawable? = null,
right: Drawable? = null,
bottom: Drawable? = null
) =
/*setCompoundDrawablesWithIntrinsicBounds(left, top, right, bottom)*/
setCompoundDrawablesWithIntrinsicBounds(null, null, null, null)
companion object {
@Deprecated("This constant has been replace with an enum.", ReplaceWith("Separator.NONE"))
const val NO_SEPARATOR = 0
@Deprecated("This constant has been replace with an enum.", ReplaceWith("Separator.SPACES"))
const val SPACES_SEPARATOR = 1
@Deprecated("This constant has been replace with an enum.", ReplaceWith("Separator.DASHES"))
const val DASHES_SEPARATOR = 2
@Deprecated("This constant has been replace with an enum.", ReplaceWith("null"))
const val NONE = 0
@Deprecated("This constant has been replace with an enum.", ReplaceWith("Card.VISA"))
const val VISA = 1
@Deprecated("This constant has been replace with an enum.", ReplaceWith("Card.MASTERCARD"))
const val MASTERCARD = 2
@Deprecated("This constant has been replace with an enum.", ReplaceWith("Card.AMEX"))
const val AMEX = 4
@Deprecated("This constant has been replace with an enum.", ReplaceWith("Card.DISCOVER"))
const val DISCOVER = 8
}
}
2. paste this style
<declare-styleable name="EditTextForCards">
<attr name="separator" format="enum">
<enum name="no_separator" value="0" />
<enum name="spaces" value="1" />
<enum name="dashes" value="2" />
</attr>
<attr name="disabledCards">
<flag name="none" value="0" />
<flag name="visa" value="1" />
<flag name="mastercard" value="2" />
<flag name="amex" value="4" />
<flag name="discover" value="8" />
</attr>
<attr name="drawableGravity">
<enum name="start" value="0" />
<enum name="end" value="1" />
<enum name="left" value="2" />
<enum name="right" value="3" />
</attr>
</declare-styleable>
3. In your layout file, use it by
<EditTextForCards
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="@dimen/dp_5"
android:digits="0123456789 "
android:hint="@string/card_number"
android:padding="@dimen/dp_20"
android:textColor="@android:color/white"
android:textColorHint="@android:color/white"
android:textSize="@dimen/sp_16"
app:separator="spaces" />
Here is a cleaner solution using regular expressions. Although regular expressions can be inefficient, they would be sufficient in this case since it's processing a string of at most 19 characters, even if the processing occurs after each key press.
editTxtCardNumber.addTextChangedListener(new TextWatcher() {
@Override
public void onTextChanged(CharSequence s, int arg1, int arg2,
int arg3) { }
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
@Override
public void afterTextChanged(Editable s) {
String initial = s.toString();
// remove all non-digits characters
String processed = initial.replaceAll("\\D", "");
// insert a space after all groups of 4 digits that are followed by another digit
processed = processed.replaceAll("(\\d{4})(?=\\d)", "$1 ");
// to avoid stackoverflow errors, check that the processed is different from what's already
// there before setting
if (!initial.equals(processed)) {
// set the value
s.replace(0, initial.length(), processed);
}
}
});
After finding multiple answers that are 'OK'. I moved towards a better TextWatcher which is designed to work correctly and independently from the TextView
.
TextWatcher class is as follows:
/**
* Formats the watched EditText to a credit card number
*/
public static class FourDigitCardFormatWatcher implements TextWatcher {
// Change this to what you want... ' ', '-' etc..
private static final char space = ' ';
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void afterTextChanged(Editable s) {
// Remove spacing char
if (s.length() > 0 && (s.length() % 5) == 0) {
final char c = s.charAt(s.length() - 1);
if (space == c) {
s.delete(s.length() - 1, s.length());
}
}
// Insert char where needed.
if (s.length() > 0 && (s.length() % 5) == 0) {
char c = s.charAt(s.length() - 1);
// Only if its a digit where there should be a space we insert a space
if (Character.isDigit(c) && TextUtils.split(s.toString(), String.valueOf(space)).length <= 3) {
s.insert(s.length() - 1, String.valueOf(space));
}
}
}
}
Then add it to your TextView as you would any other TextWatcher
.
{
//...
mEditTextCreditCard.addTextChangedListener(new FourDigitCardFormatWatcher());
}
This will auto delete the space sensibly going back so the user can actually do less keystrokes when editing.
If you are using inputType="numberDigit"
this will disable the '-' and ' ' chars, so I recommend using, inputType="phone"
. This enables other chars, but just use a custom inputfilter and problem solved.
This implementation ensures correct placement of spacing chars, even if the user edits mid-string. Other characters that show up on the soft keyboard (such as dash) are also supported; that is, the user can't enter them. One improvement that could be made: this implementation doesn't allow for the deletion of spacing characters mid-string.
public class CreditCardTextWatcher implements TextWatcher {
public static final char SPACING_CHAR = '-'; // Using a Unicode character seems to stuff the logic up.
@Override
public void beforeTextChanged(final CharSequence s, final int start, final int count, final int after) { }
@Override
public void onTextChanged(final CharSequence s, final int start, final int before, final int count) { }
@Override
public void afterTextChanged(final Editable s) {
if (s.length() > 0) {
// Any changes we make to s in here will cause this method to be run again. Thus we only make changes where they need to be made,
// otherwise we'll be in an infinite loop.
// Delete any spacing characters that are out of place.
for (int i=s.length()-1; i>=0; --i) {
if (s.charAt(i) == SPACING_CHAR // There is a spacing char at this position ,
&& (i+1 == s.length() // And it's either the last digit in the string (bad),
|| (i+1) % 5 != 0)) { // Or the position is not meant to contain a spacing char?
s.delete(i,i+1);
}
}
// Insert any spacing characters that are missing.
for (int i=14; i>=4; i-=5) {
if (i < s.length() && s.charAt(i) != SPACING_CHAR) {
s.insert(i, String.valueOf(SPACING_CHAR));
}
}
}
}
}
Works well with an appropriate PasswordTransformationMethod
implementation to mask CC digits.
None of above answers is perfect for me. I created one that solves the start-string/end-string/mid-string issues. Copy & Paste should also work fine. This supports Mastercard, Visa and Amex. You can change the separator. If you don't need payment method type just remove it. It is Kotlin though. The idea is simple. Everytime when text changed I remove all separators and re-added them base on the format. The solves the issue start-string/mid-string issues. Then the only problem is that you need to work out the the right text position after separators added.
fun addCreditCardNumberTxtWatcher(et: EditText, separator: Char, paymentMethodType: PaymentMethodType): TextWatcher {
val tw = object : TextWatcher {
var mBlock = false
override fun afterTextChanged(s: Editable) {
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
Logger.d("_debug", "s: $s, start: $start, count: $count, after $after")
}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
if (mBlock)
return
var lastPos = et.selectionStart
val oldStr = et.text.toString().replace(separator.toString(), "", false)
var newFormattedStr = ""
if (before > 0) {
if (lastPos > 0 && et.text.toString()[lastPos - 1] == separator) lastPos--
}
Logger.d("_debug", "lastPos: $lastPos, s: $s, start: $start, before: $before, count $count")
mBlock = true
oldStr.forEachIndexed { i, c ->
when (paymentMethodType) {
PaymentMethodType.MASTERCARD, PaymentMethodType.VISA -> {
if (i > 0 && i % 4 == 0) {
newFormattedStr += separator
}
}
PaymentMethodType.AMERICAN_EXPRESS -> {
if (i == 4 || i == 10 || i == 15) {
newFormattedStr += separator
}
}
}
newFormattedStr += c
}
et.setText(newFormattedStr)
if (before == 0) {
if (et.text.toString()[lastPos - 1] == separator) lastPos++
}
et.setSelection(lastPos)
mBlock = false
}
}
et.addTextChangedListener(tw)
return tw
}