0

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);
        }
    }
Stephen McCormick
  • 1,706
  • 22
  • 38

2 Answers2

0

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!

Stephen McCormick
  • 1,706
  • 22
  • 38
0

I had the same problem and it was really hard to find something as useful and good as @Stephen McCormick's answer. I used it, but it wasn't 100% complete and I had to make some changes to make it work correctly with my mask: "##.# kg".

I made some changes and commented out the code so that everyone can understand it easier. But of course, all this thanks to Stephen.

Thank you so much Stephen!!

If anyone else wants it, in kotlin, here it is:

custom_formatted_input.xml:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical">

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

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

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


</LinearLayout>

Model Field.kt:

data class Field (
    val fieldName: String,
    val defaultValue: String,
    val value: String?,
    val displayMask: String,
    val placeholderText: String,
)

attrs.xml:

    <declare-styleable name="CustomFormattedInput">
        <attr name="custom_input_field_name" format="string" />
        <attr name="custom_input_default_value" format="string" />
        <attr name="custom_input_value" format="string" />
        <attr name="custom_input_mask" format="string" />
        <attr name="custom_input_place_holder" format="string" />
    </declare-styleable>

CustomFormattedInput.kt:


import android.content.Context
import android.content.res.Configuration
import android.text.Editable
import android.text.TextWatcher
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import br.com.predikta.commons.R
import br.com.predikta.commons.domain.model.Field
import br.com.predikta.commons.extentions.addOnTextChange
import br.com.predikta.commons.ui.utilities.Utilities
import kotlinx.android.synthetic.main.custom_formatted_input.view.*


class CustomFormattedInput @JvmOverloads constructor(
    context: Context,
    attributeSet: AttributeSet? = null,
    defStyleAttr: Int = 0
) : LinearLayout(context, attributeSet, defStyleAttr) {
    private val mContext: Context = context

    private lateinit var mField: Field
    private lateinit var mName: TextView
    private lateinit var mEntry: EditText
    private var mEnableEvents = true
    private var mPlaceholderText = ""

    private fun inflaterView() {
        val inflater = mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
        inflater.inflate(R.layout.custom_formatted_input, this)
    }

    /**
     * To Fill in the fields of mask, field name and placeHolder from xml
     */
    private fun setupView(attrs: AttributeSet?) {
        attrs?.let {
            val typeArray =
                context.obtainStyledAttributes(it, R.styleable.CustomFormattedInput)

            val mFieldName =
                typeArray.getString(R.styleable.CustomFormattedInput_custom_input_field_name)
            val mDefaultValue =
                typeArray.getString(R.styleable.CustomFormattedInput_custom_input_default_value)
            val mValue =
                typeArray.getString(R.styleable.CustomFormattedInput_custom_input_value)
            val mMask =
                typeArray.getString(R.styleable.CustomFormattedInput_custom_input_mask)
            val mPlaceHolder =
                typeArray.getString(R.styleable.CustomFormattedInput_custom_input_place_holder)

            mField = Field(
                fieldName = mFieldName ?: "",
                defaultValue = mDefaultValue ?: "",
                value = mValue,
                displayMask = mMask ?: "",
                placeholderText = mPlaceHolder ?: ""
            )

            typeArray.recycle()
        }

        mName = findViewById(R.id.name)
        mEntry = findViewById(R.id.entry)

        mEntry.isFocusable = true
        mEntry.setRawInputType(Configuration.KEYBOARD_QWERTY)
        mEntry.addTextChangedListener(
            MaskedWatcher(mField.displayMask)
        )
    }

    /**
     * When first render the EditText
     */
    private fun render() {
        mName.text = mField.fieldName
        mPlaceholderText = mField.placeholderText

        if (Utilities.stringIsBlank(mPlaceholderText)) {
            mPlaceholderText = mField.displayMask
        }

        mEntry.hint = mPlaceholderText

        if (!Utilities.stringIsBlank(mField.value)) {
            mEnableEvents = false
            val value: String = java.lang.String.valueOf(mField.value)
            if (value.equals(
                    mField.displayMask,
                    ignoreCase = true
                )
            ) mEntry.setText(mField.displayMask) else {
                val valueWithMask = fillValueWithMask(value, mField.displayMask)
                mEntry.setText(valueWithMask)
            }
            mEnableEvents = true
        } else if (!Utilities.stringIsBlank(mField.defaultValue)) {
            mEnableEvents = false
            val valueWithMask = fillValueWithMask(mField.defaultValue, mField.displayMask)
            mEntry.setText(valueWithMask)
            mEnableEvents = true
        } else {
            mEnableEvents = false
            mEntry.text = null
            mEnableEvents = true
        }
    }

    inner class MaskedWatcher(private val mMask: String) : TextWatcher {
        var mPrevResult = ""
        var deletePosition = 0
        private val charMaskAmount = countOccurrences(mMask)

        override fun afterTextChanged(s: Editable) {
            val value = s.toString()

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

            /**
             * prevents code from automatically setting mask to text before user clicks on The editText
             */
            if (!mEntry.isFocused) {
                return
            } else if (mEntry.isFocused &&
                (Utilities.stringIsBlank(mPrevResult) && Utilities.stringIsBlank(value)) ||
                value.length <= charMaskAmount
            ) {
                // First time in and no value, set value to mask
                /**
                 * If new value.length <= charMaskAmount, it means that user clicked and held delete
                 * button to erase all text at once
                 */

                mPrevResult = mMask
                mEntry.setText(mPrevResult)
            } else if (Utilities.stringIsBlank(mPrevResult) && !Utilities.stringIsBlank(value)) {
                /**
                 * First value, fill it with the mask and set the text
                 */
                val valueWithMask = fillValueWithMask(value, mMask)
                mPrevResult = valueWithMask
                mEntry.setText(mPrevResult)
            } 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.
                when {
                    value.length < mPrevResult.length -> {
                        mPrevResult = removeCharAt(mPrevResult, deletePosition)
                        // Deleted back to mask, reset
                        if (mPrevResult.equals(mMask, ignoreCase = true)) {
                            mPrevResult = ""
                            setFieldValue("")
                            mEntry.setText("")
                            mEntry.hint = mPlaceholderText
                            return
                        } else setFieldValue(mPrevResult)
                        mEntry.setText(mPrevResult)
                    }
                    mPrevResult.indexOf('#') != -1 -> {
                        /**
                         * If still have the mask char to be filled in, fill in the value in place
                         * of this available char mask value
                         */
                        mPrevResult = mPrevResult.replaceFirst("#".toRegex(), diff)
                        mEntry.setText(mPrevResult)
                        setFieldValue(mPrevResult)
                    }
                    else -> {
                        /**
                         * it's already all filled
                         */
                        mEntry.setText(mPrevResult)
                    }
                }
            }

            // Move cursor to next spot
            val i = mPrevResult.indexOf(CHAR_MASK_HASHTAG)
            /**
             * if the field is full (i == -1), use charMaskAmount to decrease the cursor position so that the
             * cursor does not select the mask to prevent the user from trying to delete it
             */
            if (i != -1) mEntry.setSelection(i) else mEntry.setSelection(mPrevResult.length - charMaskAmount)
        }

        /**
         * I haven't used this method and I haven't tried erasing it either to see if it makes a
         * difference. But from what I understand, I believe it is in case you want to do something
         * after each change
         */
        private fun setFieldValue(value: String) {
            //mEnableEvents = false;
            if (!mEnableEvents) {
                return
            }
            // Set the value or do whatever you want to do to save or react to the change
        }

        /** Get the number of times the specific char in your mask appears */
        private fun countOccurrences(s: String, ch: Char = CHAR_MASK_HASHTAG): Int {
            return s.filter { it == ch }.count()
        }

        /**
         * I didn't use it and I didn't study to know what it's for
         */
        private fun replaceMask(str: String): String {
            return str.replace("#".toRegex(), REPLACE_CHAR)
        }

        /**
         * After each deletion
         * IMPORTANT: You might need to add more WHEN' branches to match your mask, just like I added
         * to validate when the cursor position is in place of the end dot
         */
        private fun removeCharAt(str: String, pos: Int): String {
            val info = StringBuilder(str)
            // If the position is a mask character, change it, else ignore the change
            return when {
                mMask[pos] == '#' -> {
                    info.setCharAt(pos, '#')
                    info.toString()
                }
                /**
                 * In my case, if the position is the DOT, change the previous number to the mask,
                 * to avoid deleting the DOT and to prevent the cursor from getting stuck in the same
                 * position and not returning to the position before the DOT
                 */
                mMask[pos] == '.' -> {
                    info.setCharAt(pos - 1, '#')
                    info.toString()
                }
                else -> {
                    Toast.makeText(
                        mContext,
                        "The mask value can't be deleted, only modifiable portion",
                        Toast.LENGTH_SHORT
                    ).show()
                    str
                }
            }
        }

        override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {}

        /**
         * Get the position where the user has just deleted. This code comes before and after the
         * mask did the change. So it get the exactly position where the user deleted
         */
        override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
            deletePosition = start
        }
    }

    init {
        inflaterView()
        setupView(attributeSet)
        render()
    }

    companion object {
        private const val CHAR_MASK_HASHTAG = '#'
        private const val REPLACE_CHAR = " " // Replace missing data with blank

        /**
         * Fill in the value within the mask provided.
         * IMPORTANT: you may need to change this method if your mask is different
         */
        fun fillValueWithMask(value: String, mask: String): String {
            val result = StringBuffer(mask)
            var i = 0
            while (i < value.length && i <= mask.length - 1) {
                if (mask[i] == '#' && value[i] != ' ' && Character.isDigit(value[i])) result.setCharAt(
                    i,
                    value[i]
                )
                i++
            }
            return result.toString()
        }
    }
}

and Utilities.kt code:

import kotlin.math.min

class Utilities {
    companion object {
        fun stringIsBlank(stringValue: String?): Boolean {
            return stringValue?.trim { it <= ' ' }?.isEmpty() ?: true
        }

        fun difference(str1: String, str2: String): String {
            val at: Int = indexOfDifference(str1, str2)
            return if (at == -1) {
                ""
            } else str2.substring(at, at + 1)
        }

        /**
         * Find the position where the string has the first difference
         */
        private fun indexOfDifference(str1: String, str2: String): Int {
            val minLen = min(str1.length, str2.length)

            for (i in 0 until minLen) {
                val char1: Char = str1[i]
                val char2: Char = str2[i]

                if (char1 != char2) {
                    return i
                }
            }

            return -1
        }
    }
}

And an example of how to use it in your xml:

<br.com.example.CustomFormattedInput
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:layout_constraintTop_toTopOf="parent"
    app:custom_input_field_name="field name here"
    app:custom_input_mask="##.# kg"
    app:custom_input_place_holder="ex: 85 kg"/>
Giulio
  • 13
  • 2