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:
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:
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!