Last active
November 16, 2020 14:05
-
-
Save hidroh/77ca470bbb8b5b556901 to your computer and use it in GitHub Desktop.
Android numeric EditText widget that automatically formats input number
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package io.github.hidroh.android.widget; | |
import android.content.Context; | |
import android.text.Editable; | |
import android.text.TextWatcher; | |
import android.util.AttributeSet; | |
import android.view.View; | |
import android.widget.EditText; | |
import org.apache.commons.lang3.StringUtils; | |
import java.text.DecimalFormatSymbols; | |
import java.text.NumberFormat; | |
import java.text.ParseException; | |
import java.util.ArrayList; | |
import java.util.List; | |
/** | |
* {@link android.widget.EditText} widget for numeric input, with support for realtime formatting. | |
* Supported {@link android.text.InputType} flags are {@link android.text.InputType.TYPE_CLASS_NUMBER} | |
* and {@link android.text.InputType.TYPE_NUMBER_FLAG_DECIMAL}. | |
* Accepted input digits and symbols should be set via{@link android.R.styleable#TextView_digits}. | |
*/ | |
public class NumericEditText extends EditText { | |
private final char GROUPING_SEPARATOR = DecimalFormatSymbols.getInstance().getGroupingSeparator(); | |
private final char DECIMAL_SEPARATOR = DecimalFormatSymbols.getInstance().getDecimalSeparator(); | |
private final String LEADING_ZERO_FILTER_REGEX = "^0+(?!$)"; | |
private String mDefaultText = null; | |
private String mPreviousText = ""; | |
private String mNumberFilterRegex = "[^\\d\\" + DECIMAL_SEPARATOR + "]"; | |
private char mDecimalSeparator = DECIMAL_SEPARATOR; | |
private boolean hasCustomDecimalSeparator = false; | |
/** | |
* Interface to notify listeners when numeric value has been changed or cleared | |
*/ | |
public interface NumericValueWatcher { | |
/** | |
* Fired when numeric value has been changed | |
* @param newValue new numeric value | |
*/ | |
void onChanged(double newValue); | |
/** | |
* Fired when numeric value has been cleared (text field is empty) | |
*/ | |
void onCleared(); | |
} | |
private List<NumericValueWatcher> mNumericListeners = new ArrayList<NumericValueWatcher>(); | |
private final TextWatcher mTextWatcher = new TextWatcher() { | |
private boolean validateLock = false; | |
@Override | |
public void afterTextChanged(Editable s) { | |
if (validateLock) { | |
return; | |
} | |
// valid decimal number should not have more than 2 decimal separators | |
if (StringUtils.countMatches(s.toString(), String.valueOf(mDecimalSeparator)) > 1) { | |
validateLock = true; | |
setText(mPreviousText); // cancel change and revert to previous input | |
setSelection(mPreviousText.length()); | |
validateLock = false; | |
return; | |
} | |
if (s.length() == 0) { | |
handleNumericValueCleared(); | |
return; | |
} | |
setTextInternal(format(s.toString())); | |
setSelection(getText().length()); | |
handleNumericValueChanged(); | |
} | |
@Override | |
public void beforeTextChanged(CharSequence s, int start, int count, int after) { | |
// do nothing | |
} | |
@Override | |
public void onTextChanged(CharSequence s, int start, int before, int count) { | |
// do nothing | |
} | |
}; | |
private void handleNumericValueCleared() { | |
mPreviousText = ""; | |
for (NumericValueWatcher listener : mNumericListeners) { | |
listener.onCleared(); | |
} | |
} | |
private void handleNumericValueChanged() { | |
mPreviousText = getText().toString(); | |
for (NumericValueWatcher listener : mNumericListeners) { | |
listener.onChanged(getNumericValue()); | |
} | |
} | |
public NumericEditText(Context context, AttributeSet attrs) { | |
super(context, attrs); | |
addTextChangedListener(mTextWatcher); | |
setOnClickListener(new View.OnClickListener() { | |
@Override | |
public void onClick(View v) { | |
// disable moving cursor | |
setSelection(getText().length()); | |
} | |
}); | |
} | |
/** | |
* Add listener for numeric value changed events | |
* @param watcher listener to add | |
*/ | |
public void addNumericValueChangedListener(NumericValueWatcher watcher) { | |
mNumericListeners.add(watcher); | |
} | |
/** | |
* Remove all listeners to numeric value changed events | |
*/ | |
public void removeAllNumericValueChangedListeners() { | |
while (!mNumericListeners.isEmpty()) { | |
mNumericListeners.remove(0); | |
} | |
} | |
/** | |
* Set default numeric value and how it should be displayed, this value will be used if | |
* {@link #clear} is called | |
* @param defaultNumericValue numeric value | |
* @param defaultNumericFormat display format for numeric value | |
*/ | |
public void setDefaultNumericValue(double defaultNumericValue, final String defaultNumericFormat) { | |
mDefaultText = String.format(defaultNumericFormat, defaultNumericValue); | |
if (hasCustomDecimalSeparator) { | |
// swap locale decimal separator with custom one for display | |
mDefaultText = StringUtils.replace(mDefaultText, | |
String.valueOf(DECIMAL_SEPARATOR), String.valueOf(mDecimalSeparator)); | |
} | |
setTextInternal(mDefaultText); | |
} | |
/** | |
* Use specified character for decimal separator. This will disable formatting. | |
* This must be called before {@link #setDefaultNumericValue} if any | |
* @param customDecimalSeparator decimal separator to be used | |
*/ | |
public void setCustomDecimalSeparator(char customDecimalSeparator) { | |
mDecimalSeparator = customDecimalSeparator; | |
hasCustomDecimalSeparator = true; | |
mNumberFilterRegex = "[^\\d\\" + mDecimalSeparator + "]"; | |
} | |
/** | |
* Clear text field and replace it with default value set in {@link #setDefaultNumericValue} if | |
* any | |
*/ | |
public void clear() { | |
setTextInternal(mDefaultText != null ? mDefaultText : ""); | |
if (mDefaultText != null) { | |
handleNumericValueChanged(); | |
} | |
} | |
/** | |
* Return numeric value repesented by the text field | |
* @return numeric value or {@link Double.NaN} if not a number | |
*/ | |
public double getNumericValue() { | |
String original = getText().toString().replaceAll(mNumberFilterRegex, ""); | |
if (hasCustomDecimalSeparator) { | |
// swap custom decimal separator with locale one to allow parsing | |
original = StringUtils.replace(original, | |
String.valueOf(mDecimalSeparator), String.valueOf(DECIMAL_SEPARATOR)); | |
} | |
try { | |
return NumberFormat.getInstance().parse(original).doubleValue(); | |
} catch (ParseException e) { | |
return Double.NaN; | |
} | |
} | |
/** | |
* Add grouping separators to string | |
* @param original original string, may already contains incorrect grouping separators | |
* @return string with correct grouping separators | |
*/ | |
private String format(final String original) { | |
final String[] parts = original.split("\\" + mDecimalSeparator, -1); | |
String number = parts[0] // since we split with limit -1 there will always be at least 1 part | |
.replaceAll(mNumberFilterRegex, "") | |
.replaceFirst(LEADING_ZERO_FILTER_REGEX, ""); | |
// only add grouping separators for non custom decimal separator | |
if (!hasCustomDecimalSeparator) { | |
// add grouping separators, need to reverse back and forth since Java regex does not support | |
// right to left matching | |
number = StringUtils.reverse( | |
StringUtils.reverse(number).replaceAll("(.{3})", "$1" + GROUPING_SEPARATOR)); | |
// remove leading grouping separator if any | |
number = StringUtils.removeStart(number, String.valueOf(GROUPING_SEPARATOR)); | |
} | |
// add fraction part if any | |
if (parts.length > 1) { | |
number += mDecimalSeparator + parts[1]; | |
} | |
return number; | |
} | |
/** | |
* Change display text without triggering numeric value changed | |
* @param text new text to apply | |
*/ | |
private void setTextInternal(String text) { | |
removeTextChangedListener(mTextWatcher); | |
setText(text); | |
addTextChangedListener(mTextWatcher); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
The
setSelection(getText().length());
sets the cursor to the end of the string. What happens if I want to edit the number in the middle?Let's say I have the number
2845x
with x marking the current spot of the cursor.I click between 2 and 8 so I have
2x845
and I enter the number 1. The desired output would be21x845
but with your code I would end up with
21845x
.