Android 仅允许整数但掩码中包含文本的掩码格式化程序

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

在 Android 我需要创建一个编辑输入,当用户输入带有“#”符号的值时,该输入将包含不会更改的静态文本元素以及需要用数字替换的其他值用来。替换只能是 0-9 之间的整数。例如,掩码可能是 "SERIAL NO #####",其中当用户键入数字时,“#”值将被替换,最终给出字符串结果 "SERIAL NO 12309"。

我们有使用 MaskFormatter 的现有代码,但它会抛出对其中包含任何字符的掩码的解析异常,如上所示(尽管它仅适用于“#”)。

此外,这个掩码可以有很大的不同。从像“####”这样的简单掩码,到像“###A-##WHATEVER”这样的更复杂的掩码,再到 "A#A$#RRT#",其中只有“#”在输入时允许输入数值。

有没有简单的方法可以做到这一点,还是我需要自己编写解析代码? MaskFormatter 是正确的方法还是有更优雅的机制?我很确定我可以编写自定义代码来执行此操作,但我更喜欢标准解决方案。

这是该领域的可视化:

这是现有代码(不是我写的,一直存在):

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

好的,现在我知道为什么没有人回答这个问题了——这很讨厌。我做了很多研究,却找不到任何类似的东西——也许我不擅长搜索。首先介绍一下我的研究历史。我原以为我可以在现场观看击键并做出反应。并不真地。您可以使用硬键盘执行此操作,但不能使用软键盘。我尝试了多种针对三星设备的方法,但均未成功。也许有人知道一个技巧,但我找不到。 所以我选择了唯一可用的选项——TextWatcher。唯一真正的问题是你无法真正看到按下了什么键做出反应(添加了数字还是按下了删除键?),所以你必须用当前更改的字符串检查前一个字符串并尽力确定发生了什么变化以及如何应对。

只是为了提供帮助,我实现的行为基本上是让所有用户输入数字 (0-9) 并且不更改掩码的其他元素。我还需要在他们输入或删除项目时将光标移动到正确的位置。此外,如果他们删除我们需要删除适当的元素并放回掩码。

例如,如果掩码是 "ADX-###-R" 那么在您键入时会发生以下情况:

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"

这就是它的要点。我们还需要 Hint/Placeholder 和默认值,所有这些我都留在了里面。现在是代码。

这是它的屏幕截图:

首先是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>

主域代码:

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

    }
}

实用代码:

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

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

字段 class...您需要添加 getter 和 setter:

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

一些最后的想法。基本机制是将前一个字符串与当前字符串进行比较。如果新字符串更小,那么我们将删除并使用 deletePosition 只要该位置与掩码中的“#”匹配,因为其他字符是不可修改的。还存在先前值出现的问题 - 并且假定如果该值出现在“#”中,则缺少的值将被替换为“”(空白)。 Field 不是必需的,但它是一个助手 class,在我们的例子中它具有大量其他功能。希望这对某人有所帮助!

我遇到了同样的问题,很难找到像@Stephen McCormick 的回答一样有用和好的东西。我使用了它,但它不是 100% 完成的,我必须进行一些更改才能使其与我的面罩一起正常工作:“##.# kg”。

为了让大家更容易理解,我做了一些修改,并注释掉了代码。当然,这一切都要感谢斯蒂芬。

非常感谢斯蒂芬!!

如果其他人想要它,在 kotlin 中,它是:

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>

型号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()
        }
    }
}

和Utilities.kt代码:

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

以及如何在 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"/>