Skip to content

Instantly share code, notes, and snippets.

@hidroh
Last active November 16, 2020 14:05
Show Gist options
  • Save hidroh/77ca470bbb8b5b556901 to your computer and use it in GitHub Desktop.
Save hidroh/77ca470bbb8b5b556901 to your computer and use it in GitHub Desktop.
Android numeric EditText widget that automatically formats input number
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);
}
}
@arodrigueze0215
Copy link

How can I make instance this class?

@TheCrafter
Copy link

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 be

21x845

but with your code I would end up with 21845x.

@Edijae
Copy link

Edijae commented Feb 24, 2017

yah how can the issue raised by @TheCrafter be solved?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment