Monday, December 5, 2011

Using a mask with EditText



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("(###) ###-##-##")
        )





33 comments:

  1. Some bugs:

    Using 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.

    ReplyDelete
  2. Thanks for the post.
    I 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...

    ReplyDelete
  3. Yes, - is deleted normally by backspace. My workaround was to exclude the space: (##)####-####

    It would be nice to blog an example of validation: using those Watchers to invoke a function to validate a field =)

    ReplyDelete
  4. About bag on the non-removable blank space. I just replaced the if statement with while statement and this bag is gone.
    In 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?

    ReplyDelete
    Replies
    1. Sorry for the delay. I was very busy. It's ok.
      I'll add your patch as soon as possible.

      Delete
    2. This comment has been removed by the author.

      Delete
    3. Note if your using the ' literal in your mask the mask.charAt(value.length()-1))
      needs to be of a different mask eg posMask = mask.replaceAll("'","");

      therefor
      while (value.charAt(value.length()-1) == posMask.charAt(value.length()-1))

      Delete
  5. Hello,

    I'm sure this is a newbie question, but ...

    where are defined the XXXXCharacter classes, i.e.

    MaskCharacter,
    DigitMaskCharacter,
    AlphaNumericCharacter,
    LiteralCharacter ... etc.

    Thanks in advance.

    ReplyDelete
    Replies
    1. The are private classes in MaskedFormatter.java file.

      Delete
    2. Did the code changed? I do not see them either (neither as private classes)

      Delete
    3. OK 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

      Delete
  6. This comment has been removed by the author.

    ReplyDelete
  7. how can I get these files as
    XXXXCharacter

    MaskCharacter,
    DigitMaskCharacter,
    AlphaNumericCharacter,
    LiteralCharacter ... etc..

    From already thank

    ReplyDelete
    Replies
    1. They are here: https://code.google.com/p/android-tips-demo/source/browse/Tips.Demo/src/com/horribile/tips/framework/MaskedFormatter.java?r=8

      Delete
  8. Thank you Mr. Horribile, saved my job. hehehe.


    Since already grateful.

    Mark Angelo.

    ReplyDelete
  9. Just could't import javax.swing :/

    ReplyDelete
    Replies
    1. There isn't Swing in Android. See the example source code. There you can find a reworked class from Swing adapted for Android.

      Delete
  10. Thanks for this, was slightly surprised this isn't in the android base. Three things though:

    1. 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.

    ReplyDelete
  11. I 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:

    After 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

    ReplyDelete
    Replies
    1. I can't reproduce. It's working in my project. Nobody complained...

      Delete
    2. I 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...

      Delete
    3. I 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...

      Delete
  12. I have SGS I9000 with 10 Cyanogen(JB 4.1). I don't have the issue.

    BTW. I'm porting now Delphi MaskedEdit and it looks good. Maybe in the nearest future I'll add the code.

    ReplyDelete
    Replies
    1. 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...

      Delete
  13. Not work correctly if "1(###)###-##-##"

    1 - "1(1"
    2 - "1(112"
    3 - "1(111)23"
    4 - "1(111)123-4"

    ...

    ReplyDelete
    Replies
    1. I don't have a time to check it right now. If you will find a solution, please let me know.

      Delete
    2. Here is my solution for this issue If its helpful.

      In 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;
      }

      Delete
    3. Also forgot the watcher needs to be changed slightly to accommodate this to:
      if(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 .

      Delete
  14. its giving error of:
    java.lang.NullPointerException: Attempt to invoke virtual method 'void android.widget.EditText.addTextChangedListener(android.text.TextWatcher)' on a null object reference

    ReplyDelete
  15. I'm getting "StackOverflowError" because of this line:

    s.replace(0, s.length(), value);

    How can I fix it?

    ReplyDelete