Skip to content
Advertisement

Android mask formatter that allows only Integers but with text in mask

On Android I need to create a edit input that will have static text elements that do not change and other values that need to be replaced by numbers when the user types in values which the “#” symbol is used. The replacements should only be integers between 0-9. For example, the mask might be “SERIAL NO #####”, where as the user types in the numbers the “#” values would be replaced, ultimately giving the string result “SERIAL NO 12309”.

We have existing code that uses MaskFormatter, but it is throwing out parsing exceptions for masks with any characters in them, like above (though it works fine with just “#”).

Additionally this mask can vary widely. From simple masks like “####”, to more complex masks like “###A-##WHATEVER” to “A#A$#RRT#”, where only the “#” should allow numeric values when typing.

Is there a simple way to do this or do I need to write parsing code of my own? Is MaskFormatter the right approach or is there a more elegant mechanism? I am pretty sure I can write custom code to do this, but I would prefer a standard solution.

Here is a visualization of the field:

enter image description here

And here is the existing code (I didn’t write it, been around for forever):

    public class MaskedWatcher implements TextWatcher {

    private String mMask;
    String mResult = "";    
    String mPrevResult = "";

    public MaskedWatcher(String mask){
        mMask = mask;
    }

    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(true);
            formatter.setPlaceholderCharacter((char)1);

            // get a string with applied mask and placeholder chars
            value = formatter.valueToString(value);

            try{
                // find first placeholder
                if ( value.indexOf((char)1) != -1) {
                    value = value.substring(0, value.indexOf((char)1));

                    //process a mask char
                    if(value.charAt(value.length()-1) == mask.charAt(value.length()-1) && ((value.length()-1) >= 0)){
                        value = value.substring(0, value.length() - 1);
                    }
                }
            }
            catch(Exception e){
                Utilities.logException(e);
            }

            // if we are deleting characters reset value and start over
            if(mPrevResult.trim().length() > value.trim().length()) {
                value = "";
            }

            setFieldValue(value);
            mResult = value;
            mPrevResult = value;
            s.replace(0, s.length(), value);
        } 
        catch (ParseException e) {
            //the entered value does not match a mask
            if(mResult.length() >= mMask.length()) {
                if(value.length() > mMask.length()) {
                    value = value.substring(0, mMask.length());
                }
                else {
                    value = "";
                    setFieldValue(value);
                    mPrevResult = value;
                    mResult = value;
                }
            }
            else {
                int offset = e.getErrorOffset();
                value = removeCharAt(value, offset);
            }
            s.replace(0, s.length(), value);
        }
    }

Advertisement

Answer

Ok, now I know why no one answered this one – it is nasty. I did a lot of research and could find nothing even remotely similar – maybe I am not good at searching. First a little history of my research. I thought originally I could just watch keystrokes in the field and react to those. Not really. You can do so with hard keyboards, but not soft. I tried various methods against a Samsung device without success. Maybe someone knows a trick but I could not find it. So I went to the only option available -the TextWatcher. The only real issue is that you can’t really see what key was pressed to react (was a number added or the delete key hit?), so you have to check the previous string with the current changed string and do your best to determine what has changed and what to do about it.

Just to help, the behavior I achieved was basically to all users to enter in numbers (0-9) and to NOT change the other elements of the mask. Also I needed to move the cursor to the proper position as they entered or deleted items. Additionally if they we deleting we needed to remove the proper element and place back the mask.

For example, if the mask was “ADX-###-R” then following would happen as you type:

Given : "ADX-###-R" Typing: "4" Results: "ADX-4##-R" Cursor at "4"
Given : "ADX-4##-R" Typing: "3" Results: "ADX-43#-R" Cursor at "3"
Given : "ADX-43#-R" Typing: "1" Results: "ADX-431-R" Cursor at end of string
Given : "ADX-431-R" Typing: "Del" Results: "ADX-43#-R" Cursor at "3"

That is the gist of it. We also have the requirement for Hint/Placeholder and Default values, all of which I have left in. Now the code.

Here is a screen shot of what it looks like:

enter image description here

First the XML:

<LinearLayout 
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:focusable="true"
    android:focusableInTouchMode="true">

    <TextView
        android:id="@+id/name"
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:textSize="18sp"
        android:textStyle="normal"
        android:paddingLeft="5dp"
        android:paddingRight="5dp"
        android:text="" />

    <EditText android:id="@+id/entry"
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:gravity="right"
        android:singleLine="true"
        android:maxLines="1"
        android:ellipsize="end" /> 

    <View
        android:layout_marginTop="8dp"
        android:layout_width="match_parent"
        android:layout_height="1dp"
        android:background="@android:color/darker_gray"
        android:visibility="gone" />

</LinearLayout>

The Main field code:

public class FormattedInput extends LinearLayout {

    private Context mContext;
    private Field mField;
    private TextView mName;
    private EditText mEntry;
    private Boolean mEnableEvents = true;
    private String mPlaceholderText = "";
    private final static String REPLACE_CHAR = " "; // Replace missing data with blank

    public FormattedInput(Context context, Field field) {
        super(context);

        mContext = context;
        mField = field;

        initialize();
        render(mField);
    }

    private void initialize() {

        LayoutInflater inflater = (LayoutInflater)mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
        inflater.inflate(R.layout.field_formatted_input, this);

        // setup fields
        mName = (TextView)findViewById(R.id.name);
        mEntry = (EditText)findViewById(R.id.entry);
        mEntry.setFocusable(true);
        mEntry.setRawInputType(Configuration.KEYBOARD_QWERTY);
        mEntry.addTextChangedListener(
                new MaskedWatcher(mField.getDisplayMask())
        );
    }

    public void render(Field field) {
        mName.setText(mField.getFieldName());

        mPlaceholderText = mField.getPlaceholderText();
        if(Utilities.stringIsBlank(mPlaceholderText)) {
            mPlaceholderText = mField.getDisplayMask();
        }
        mEntry.setHint(mPlaceholderText);
        mEntry.setHintTextColor(Color.GRAY);

        if(!Utilities.stringIsBlank(mField.getValue())) {
            mEnableEvents = false;
            String value =  String.valueOf(mField.getValue());
            if (value.equalsIgnoreCase(mField.getDisplayMask()))
                mEntry.setText(mField.getDisplayMask());
            else {
                String val = fillValueWithMask(value, mField.getDisplayMask());
                mEntry.setText(val);
            }
            mEnableEvents = true;
        }
        else if (!Utilities.stringIsBlank(mField.getDefaultValue())) {
            mEnableEvents = false;
            String val = fillValueWithMask(mField.getDefaultValue(), mField.getDisplayMask());
            mEntry.setText(val);
            mEnableEvents = true;
        }
        else {
            mEnableEvents = false;
            mEntry.setText(null);
            mEnableEvents = true;
        }
    }

    public static String fillValueWithMask(String value, String mask) {
        StringBuffer result = new StringBuffer(mask);
        for (int i = 0; i < value.length() && i <= mask.length()-1 ; i++){
            if (mask.charAt(i) == '#' && value.charAt(i) != ' ' && Character.isDigit(value.charAt(i)))
                result.setCharAt(i,value.charAt(i));
        }
        return result.toString();
    }

    public class MaskedWatcher implements TextWatcher {

        private String mMask;
        String mResult = "";    
        String mPrevResult = "";
        int deletePosition = 0;

        public MaskedWatcher(String mask){
            mMask = mask;
        }

        public void afterTextChanged(Editable s) {

            String value = s.toString();

            // No Change, return - or reset of field
            if (value.equals(mPrevResult) && (!Utilities.stringIsBlank(value) && !Utilities.stringIsBlank(mPrevResult))) {
                return;
            }

            String diff = value;
            // First time in and no value, set value to mask
            if (Utilities.stringIsBlank(mPrevResult) && Utilities.stringIsBlank(value)) {
                mPrevResult = mMask;
                mEntry.setText(mPrevResult);
            }
            // If time, but have value
            else if (Utilities.stringIsBlank(mPrevResult) && !Utilities.stringIsBlank(value)) {
                mPrevResult = value;
                mEntry.setText(mPrevResult);
            }
            // Handle other cases of delete and new value, or no more typing allowed
            else {
                // If the new value is larger or equal than the previous value, we have a new value
                if (value.length() >= mPrevResult.length())
                    diff = Utilities.difference(mPrevResult, value);

                // See if new string is smaller, if so it was a delete.
                if (value.length() < mPrevResult.length()) {
                    mPrevResult = removeCharAt(mPrevResult, deletePosition);
                    // Deleted back to mask, reset
                    if (mPrevResult.equalsIgnoreCase(mMask)) {
                        mPrevResult = "";
                        setFieldValue("");
                        mEntry.setText("");
                        mEntry.setHint(mPlaceholderText);
                        return;
                    }
                    // Otherwise set value
                    else
                        setFieldValue(mPrevResult);
                    mEntry.setText(mPrevResult);
                }
                // A new value was added, add to end
                else if (mPrevResult.indexOf('#') != -1) {
                    mPrevResult = mPrevResult.replaceFirst("#", diff);
                    mEntry.setText(mPrevResult);
                    setFieldValue(mPrevResult);
                }
                // Unallowed change, reset the value back
                else {
                    mEntry.setText(mPrevResult);
                }
            }

            // Move cursor to next spot
            int i = mPrevResult.indexOf('#');
            if (i != -1)
                mEntry.setSelection(i);
            else
                mEntry.setSelection(mPrevResult.length());
        }

        private void setFieldValue(String value) {
            //mEnableEvents = false;
            if(mEnableEvents == false) {
                return;
            }
            // Set the value or do whatever you want to do to save or react to the change
        }

        private String replaceMask(String str) {
            return str.replaceAll("#",REPLACE_CHAR);
        }

        private String removeCharAt(String str, int pos) {
            StringBuilder info = new StringBuilder(str);
            // If the position is a mask character, change it, else ignore the change
            if (mMask.charAt(pos) == '#') {
                info.setCharAt(pos, '#');
                return info.toString();
            }
            else {
                Toast.makeText(mContext, "The mask value can't be deleted, only modifiable portion", Toast.LENGTH_SHORT);
                return str;
            }
        }

        public void beforeTextChanged(CharSequence s, int start, int count, int after) {
        }

        public void onTextChanged(CharSequence s, int start, int before, int count) {
            deletePosition = start;
        }

    }
}

The Utility code:

public static boolean stringIsBlank(String stringValue) {
    if (stringValue != null) {
        return stringValue.trim().length() <= 0;
    } else {
        return true;
    }
}

and

public static String difference(String str1, String str2) {
    int at = indexOfDifference(str1, str2);
    if (at == -1) {
        return "";
    }
    return str2.substring(at,at+1);
}

And the Field class…you will need to add the getters and setters:

public class Field {
    private String defaultValue;
    private Object value;
    private String displayMask;
    private String placeholderText;
}

Some final thoughts. The basic mechanism is to compare the previous string with the current string. If the new string is smaller, then we are deleting and we use the deletePosition so long as the position matches a “#” in the mask, since other characters are non-modifiable. There are also issues of a previous value coming in – and it is assumed that if that value comes in the “#” values if missing will have been replaced by ” ” (blanks). The Field is not necessary, but was a helper class that in our case has tons of other functionality. Hope this helps someone!

Advertisement