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"/>