Source Code
Русский перевод.
To continue the theme about formatting a text with regular expressions we will
implement a functionality for using a mask with the EditText control.
Sure we can use InputType, but it would be nice to have more flexible functionality.
To begin let's look at the MaskFormatter class of the Swing framework.
It has all what we need. So, let's make some changes in this class.
We leave inner classes as is. But the MaskFormatter class works with
the JFormattedTextField control. We must remove this appendix.
As a result we have something like this:
public class MaskedFormatter { // Potential values in mask. private static final char DIGIT_KEY = '#'; private static final char LITERAL_KEY = '\''; private static final char UPPERCASE_KEY = 'U'; private static final char LOWERCASE_KEY = 'L'; private static final char ALPHA_NUMERIC_KEY = 'A'; private static final char CHARACTER_KEY = '?'; private static final char ANYTHING_KEY = '*'; private static final char HEX_KEY = 'H'; /** The user specified mask. */ private String mask; /** Indicates if the value contains the literal characters. */ private boolean containsLiteralChars; private static final MaskCharacter[] EmptyMaskChars = new MaskCharacter[0]; /** List of valid characters. */ private String validCharacters; /** List of invalid characters. */ private String invalidCharacters; /** String used to represent characters not present. */ private char placeholder; /** String used for the passed in value if it does not completely * fill the mask. */ private String placeholderString; private transient MaskCharacter[] maskChars; /** Indicates if the value being edited must match the mask. */ @SuppressWarnings("unused") private boolean allowsInvalid; /** * Creates a MaskFormatter with no mask. */ public MaskedFormatter() { setAllowsInvalid(false); containsLiteralChars = true; maskChars = EmptyMaskChars; placeholder = ' '; } /** * Creates a MaskFormatter with the specified mask. * A ParseException * will be thrown if mask is an invalid mask. * * @throws ParseException if mask does not contain valid mask characters */ public MaskedFormatter(String mask) throws ParseException { this(); setMask(mask); } /** * Sets the mask dictating the legal characters. * This will throw a ParseException if mask is * not valid. * * @throws ParseException if mask does not contain valid mask characters */ public void setMask(String mask) throws ParseException { this.mask = mask; updateInternalMask(); } /** * Returns the formatting mask. * * @return Mask dictating legal character values. */ public String getMask() { return mask; } /** * Updates the internal representation of the mask. */ private void updateInternalMask() throws ParseException { String mask = getMask(); ArrayList<MaskCharacter> fixed = new ArrayList<MaskCharacter>(); ArrayList<MaskCharacter> temp = fixed; if (mask != null) { for (int counter = 0, maxCounter = mask.length(); counter < maxCounter; counter++) { char maskChar = mask.charAt(counter); switch (maskChar) { case DIGIT_KEY: temp.add(new DigitMaskCharacter()); break; case LITERAL_KEY: if (++counter < maxCounter) { maskChar = mask.charAt(counter); temp.add(new LiteralCharacter(maskChar)); } // else: Could actually throw if else break; case UPPERCASE_KEY: temp.add(new UpperCaseCharacter()); break; case LOWERCASE_KEY: temp.add(new LowerCaseCharacter()); break; case ALPHA_NUMERIC_KEY: temp.add(new AlphaNumericCharacter()); break; case CHARACTER_KEY: temp.add(new CharCharacter()); break; case ANYTHING_KEY: temp.add(new MaskCharacter()); break; case HEX_KEY: temp.add(new HexCharacter()); break; default: temp.add(new LiteralCharacter(maskChar)); break; } } } if (fixed.size() == 0) { maskChars = EmptyMaskChars; } else { maskChars = new MaskCharacter[fixed.size()]; fixed.toArray(maskChars); } } /** * Sets whether or not the value being edited is allowed to be invalid * for a length of time (that is, stringToValue throws * a ParseException). * It is often convenient to allow the user to temporarily input an * invalid value. * * @param allowsInvalid Used to indicate if the edited value must always * be valid */ public void setAllowsInvalid(boolean allowsInvalid) { this.allowsInvalid = allowsInvalid; } /** * Allows for further restricting of the characters that can be input. * Only characters specified in the mask, not in the * invalidCharacters, and in * validCharacters will be allowed to be input. Passing * in null (the default) implies the valid characters are only bound * by the mask and the invalid characters. * * @param validCharacters If non-null, specifies legal characters. */ public void setValidCharacters(String validCharacters) { this.validCharacters = validCharacters; } /** * Returns the valid characters that can be input. * * @return Legal characters */ public String getValidCharacters() { return validCharacters; } /** * Allows for further restricting of the characters that can be input. * Only characters specified in the mask, not in the * invalidCharacters, and in * validCharacters will be allowed to be input. Passing * in null (the default) implies the valid characters are only bound * by the mask and the valid characters. * * @param invalidCharacters If non-null, specifies illegal characters. */ public void setInvalidCharacters(String invalidCharacters) { this.invalidCharacters = invalidCharacters; } /** * Returns the characters that are not valid for input. * * @return illegal characters. */ public String getInvalidCharacters() { return invalidCharacters; } /** * If true, the returned value and set value will also contain the literal * characters in mask. * * For example, if the mask is '(###) ###-####', the * current value is '(415) 555-1212', and * valueContainsLiteralCharacters is * true stringToValue will return * '(415) 555-1212'. On the other hand, if * valueContainsLiteralCharacters is false, * stringToValue will return '4155551212'. * * @param containsLiteralChars Used to indicate if literal characters in * mask should be returned in stringToValue */ public void setValueContainsLiteralCharacters( boolean containsLiteralChars) { this.containsLiteralChars = containsLiteralChars; } /** * Returns true if stringToValue should return literal * characters in the mask. * * @return True if literal characters in mask should be returned in * stringToValue */ public boolean getValueContainsLiteralCharacters() { return containsLiteralChars; } /** * Sets the character to use in place of characters that are not present * in the value, ie the user must fill them in. The default value is * a space. * * This is only applicable if the placeholder string has not been * specified, or does not completely fill in the mask. * * @param placeholder Character used when formatting if the value does not * completely fill the mask */ public void setPlaceholderCharacter(char placeholder) { this.placeholder = placeholder; } /** * Returns the character to use in place of characters that are not present * in the value, ie the user must fill them in. * * @return Character used when formatting if the value does not * completely fill the mask */ public char getPlaceholderCharacter() { return placeholder; } /** * Sets the string to use if the value does not completely fill in * the mask. A null value implies the placeholder char should be used. * * @param placeholder String used when formatting if the value does not * completely fill the mask */ public void setPlaceholder(String placeholder) { this.placeholderString = placeholder; } /** * Returns the String to use if the value does not completely fill * in the mask. * * @return String used when formatting if the value does not * completely fill the mask */ public String getPlaceholder() { return placeholderString; } /** * Returns a String representation of the Object value * based on the mask. Refer to * {@link #setValueContainsLiteralCharacters} for details * on how literals are treated. * * @throws ParseException if there is an error in the conversion * @param value Value to convert * @see #setValueContainsLiteralCharacters * @return String representation of value */ public String valueToString(Object value) throws ParseException { String sValue = (value == null) ? "" : value.toString(); StringBuilder result = new StringBuilder(); String placeholder = getPlaceholder(); int[] valueCounter = { 0 }; append(result, sValue, valueCounter, placeholder, maskChars); return result.toString(); } /** * Invokes append on the mask characters in * mask. */ private void append(StringBuilder result, String value, int[] index, String placeholder, MaskCharacter[] mask) throws ParseException { for (int counter = 0, maxCounter = mask.length; counter < maxCounter; counter++) { mask[counter].append(result, value, index, placeholder); } }
And to simplify the life we will create a TextWatcher class to work with the formatter:
public class MaskedWatcher implements TextWatcher { private String mMask; String mResult = ""; public MaskedWatcher(String mask){ mMask = mask; } @Override public void afterTextChanged(Editable s) { String mask = mMask; String value = s.toString(); if(value.equals(mResult)) return; try { // prepare the formatter MaskedFormatter formatter = new MaskedFormatter(mask); formatter.setValueContainsLiteralCharacters(false); formatter.setPlaceholderCharacter((char)1); // get a string with applied mask and placeholder chars value = formatter.valueToString(value); try{ // find first placeholder value = value.substring(0, value.indexOf((char)1)); //process a mask char if(value.charAt(value.length()-1) == mask.charAt(value.length()-1)){ value = value.substring(0, value.length() - 1); } } catch(Exception e){} mResult = value; s.replace(0, s.length(), value); } catch (ParseException e) { //the entered value does not match a mask int offset = e.getErrorOffset(); value = removeCharAt(value, offset); s.replace(0, s.length(), value); } } @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { } @Override public void onTextChanged(CharSequence s, int start, int before, int count) { } public static String removeCharAt(String s, int pos) { StringBuffer buffer = new StringBuffer(s.length() - 1); buffer.append(s.substring(0, pos)).append(s.substring(pos + 1)); return buffer.toString(); } }
And now we can work with masks:
EditText phone = (EditText)findViewById(R.id.phone); phone.addTextChangedListener( new MaskedWatcher("(###) ###-##-##") )
Some bugs:
ReplyDeleteUsing the mask "(##) ####-####", backspace doesn't work while trying to delete the blank space between ) and the ####-####.
Using the physical keyboard, all key strokes are repeated (ex.: for the above mask, when we type 1234, the result is 11223344.
Thanks for the post.
ReplyDeleteI think you can't delete blank space bacause it's the mask symbol. Can you delete "-" symbol by backspace?
Concerning physical keyboard I'll see what's the matter...
Yes, - is deleted normally by backspace. My workaround was to exclude the space: (##)####-####
ReplyDeleteIt would be nice to blog an example of validation: using those Watchers to invoke a function to validate a field =)
About bag on the non-removable blank space. I just replaced the if statement with while statement and this bag is gone.
ReplyDeleteIn afterTextChanged method:
replaced
if(value.charAt(value.length()-1) == mask.charAt(value.length()-1))
with
while (value.charAt(value.length()-1) == mask.charAt(value.length()-1))
Is a good solution?
Sorry for the delay. I was very busy. It's ok.
DeleteI'll add your patch as soon as possible.
This comment has been removed by the author.
DeleteNote if your using the ' literal in your mask the mask.charAt(value.length()-1))
Deleteneeds to be of a different mask eg posMask = mask.replaceAll("'","");
therefor
while (value.charAt(value.length()-1) == posMask.charAt(value.length()-1))
Hello,
ReplyDeleteI'm sure this is a newbie question, but ...
where are defined the XXXXCharacter classes, i.e.
MaskCharacter,
DigitMaskCharacter,
AlphaNumericCharacter,
LiteralCharacter ... etc.
Thanks in advance.
The are private classes in MaskedFormatter.java file.
DeleteDid the code changed? I do not see them either (neither as private classes)
DeleteOK I found them here: https://code.google.com/p/android-tips-demo/source/browse/Tips.Demo/src/com/horribile/tips/framework/MaskedFormatter.java?r=3
DeleteThis comment has been removed by the author.
ReplyDeletehow can I get these files as
ReplyDeleteXXXXCharacter
MaskCharacter,
DigitMaskCharacter,
AlphaNumericCharacter,
LiteralCharacter ... etc..
From already thank
They are here: https://code.google.com/p/android-tips-demo/source/browse/Tips.Demo/src/com/horribile/tips/framework/MaskedFormatter.java?r=8
DeleteThank you Mr. Horribile, saved my job. hehehe.
ReplyDeleteSince already grateful.
Mark Angelo.
Just could't import javax.swing :/
ReplyDeleteThere isn't Swing in Android. See the example source code. There you can find a reworked class from Swing adapted for Android.
DeleteThanks for this, was slightly surprised this isn't in the android base. Three things though:
ReplyDelete1. I'm using it for date input "##/##/####", but it will be confusing that the slashes only show up after the third and fifth digit have been entered. I can see users trying to fill in the / by hand and failing.
2. setPlaceholder doesn't work (as I expected), I assumed I could fill in something like "dd/mm/yyyy" there, but that crashes the app. setPlaceholderCharacter('_') works as expected, but figured setPlaceholder would be the even more awesome option.
3. When using a setPlaceholderCharacter, the pointer goes to the end of the text, which is slightly confusing.
Henk.
Thanks a lot! Worked perfectly!
ReplyDeleteI have been working with this code for 2 days and I can't get it to behave properly (using Samsung Galaxy II, android 4.0.4)... When I attempt to input a phone number with consecutive numbers (i.e., 1234...), I get the following behavior:
ReplyDeleteAfter entering 1 - "(1"
2 - "(12"
3 - "(123"
4 - "(123) 4"
5 - "(123) 455" (note, 2 '5's...)
6 - "(123) 455-66" (note, 2 '6's...)
7 - "(123) 455-66-77" (note, 2 '7's...)
After backspacing over some characters, the results are even more bizarre. If I backspace to "(123) 45" and enter '6', I get "(123) 455-65-6".
Has anyone else seen this behavior?
Steve
I can't reproduce. It's working in my project. Nobody complained...
DeleteI tried it in the emulator and I don't have the issues, only on the Samsung Galaxy II device... I'll see if I can get another device to try...
DeleteI tried 2 other samsung devices, both running gingerbread and didn't have the issue with either. I need to find another ics/jellybean device to try...
DeleteI have SGS I9000 with 10 Cyanogen(JB 4.1). I don't have the issue.
ReplyDeleteBTW. I'm porting now Delphi MaskedEdit and it looks good. Maybe in the nearest future I'll add the code.
It looks like the issue has to do with the swype keyboard. I see the issue on multiple devices with the swype keyboard enabled, but not if I use the samsung or android keyboards...
Deletethanks, works perfectly :)
ReplyDeleteNot work correctly if "1(###)###-##-##"
ReplyDelete1 - "1(1"
2 - "1(112"
3 - "1(111)23"
4 - "1(111)123-4"
...
I don't have a time to check it right now. If you will find a solution, please let me know.
DeleteHere is my solution for this issue If its helpful.
DeleteIn the append there needs to be an else if clause. I have checked this for a few cases and this seems to deal with most/all of the issues.
if (getValueContainsLiteralCharacters())
{
if (inString && aChar != getChar(aChar)) {
throw new ParseException("Invalid Literal character: " +
aChar, index[0]);
}
index[0] = index[0] + 1;
}
else if(formatting.length() > buff.length())
{
index[0] = index[0] + 1;
}
Also forgot the watcher needs to be changed slightly to accommodate this to:
Deleteif(mResult.length() < value.length()) {
// prepare the formatter
MaskedFormatter formatter = new MaskedFormatter(formatMask);
formatter.setValueContainsLiteralCharacters(false);
formatter.setPlaceholderCharacter((char) 1);
// get a string with applied mask and placeholder chars
value = formatter.valueToString(value);
}
else
{
value = value + (char)1;
}
Basically the else is us checking if we are deleting and if so putting an empty on the end so that the while loop for delete can start from there. This could be done a better way but run out of time to make this nice .
its giving error of:
ReplyDeletejava.lang.NullPointerException: Attempt to invoke virtual method 'void android.widget.EditText.addTextChangedListener(android.text.TextWatcher)' on a null object reference
I'm getting "StackOverflowError" because of this line:
ReplyDeletes.replace(0, s.length(), value);
How can I fix it?
Check s.
Deletehi was just seeing if you minded a comment. i like your website and the thme you picked is super. I will be back. read
ReplyDelete