Search code examples
javaswingcurrencyformatterjformattedtextfield

JFormattedTextField currency formatter enters two 0 after inserting number


In this GIF, you can see what is wrong:

Gif of what's wrong

The code for this JFormattedTextField and the JFormatter looks like this:

    NumberFormat format = NumberFormat.getCurrencyInstance(Locale.GERMANY);
    format.setMaximumFractionDigits(2);
    NumberFormatter formatter = new NumberFormatter(format);
    formatter.setAllowsInvalid(false);
    formatter.setOverwriteMode(true);
    JFormattedTextField price = new JFormattedTextField(formatter);
    price.setValue(0.00);

Every time a number hits the separator, it adds two additional zeros. How can I prevent that?


Solution

  • You are overwriting the decimal separator. When the contained text is 1,00 €, corresponding to 1.0, and you overwrite the , with 1, the resulting text first is 1100 € which will be parsed to 1100.0 as the NumberFormat is tolerant regarding absent or misplaced grouping separators, as well as absent fractional parts. The number 1100.0 is then reformatted to 1.100,00 €.

    This repeats when you continue typing.

    The fact that the comma gets overwritten seems to be a bug. I found the following code in the NumberFormatter:

    /**
     * Subclassed to treat the decimal separator, grouping separator,
     * exponent symbol, percent, permille, currency and sign as literals.
     */
    boolean isLiteral(Map<?, ?> attrs) {
        if (!super.isLiteral(attrs)) {
            if (attrs == null) {
                return false;
            }
            int size = attrs.size();
    
            if (attrs.get(NumberFormat.Field.GROUPING_SEPARATOR) != null) {
                size--;
                if (attrs.get(NumberFormat.Field.INTEGER) != null) {
                    size--;
                }
            }
            if (attrs.get(NumberFormat.Field.EXPONENT_SYMBOL) != null) {
                size--;
            }
            if (attrs.get(NumberFormat.Field.PERCENT) != null) {
                size--;
            }
            if (attrs.get(NumberFormat.Field.PERMILLE) != null) {
                size--;
            }
            if (attrs.get(NumberFormat.Field.CURRENCY) != null) {
                size--;
            }
            if (attrs.get(NumberFormat.Field.SIGN) != null) {
                size--;
            }
            return size == 0;
        }
        return true;
    }
    

    Contrary to the documentation comment’s enumeration, which mentions “decimal separator”, the field NumberFormat.Field.DECIMAL_SEPARATOR is not handled within the method.

    Since this is a package-private method, as most of the implementation, there is no way to alter this behavior from an application. We can place a work-around at the incoming NumberFormat side, by creating a custom NumberFormat which removes the marker for the DECIMAL_SEPARATOR at the character iterator already.

    First, we add a helper method

    /**
     * Converts DECIMAL_SEPARATOR fields to literal text.
     */
    static AttributedCharacterIterator
        convertSeparatorFieldToLiteral(AttributedCharacterIterator aci) {
    
        if(aci.current() == CharacterIterator.DONE) return aci;
        int o = aci.getBeginIndex();
        StringBuilder sb = new StringBuilder(aci.getEndIndex()-o);
        do sb.append(aci.current()); while(aci.next() != CharacterIterator.DONE);
        AttributedString s = new AttributedString(sb.toString());
        for(aci.first(); aci.current() != AttributedCharacterIterator.DONE;
            aci.setIndex(aci.getRunLimit())) {
    
            Map<AttributedCharacterIterator.Attribute, Object> attr = aci.getAttributes();
            if(attr == null || attr.isEmpty()) continue;
            if(attr.containsKey(NumberFormat.Field.DECIMAL_SEPARATOR)) {
                if(attr.size() == 1) continue;
                attr = new HashMap<>(attr);
                attr.remove(NumberFormat.Field.DECIMAL_SEPARATOR);
            }
            s.addAttributes(attr, aci.getRunStart()-o, aci.getRunLimit()-o);
        }
        return s.getIterator();
    }
    

    Then, change the example usage to

    NumberFormat format = NumberFormat.getCurrencyInstance(Locale.GERMANY);
    format.setMaximumFractionDigits(2);
    NumberFormat myFormat = new NumberFormat() {
        @Override
        public StringBuffer format(double number, StringBuffer toAppendTo, FieldPosition p) {
            return format.format(number, toAppendTo, p);
        }
        @Override
        public StringBuffer format(long number, StringBuffer toAppendTo, FieldPosition pos) {
            return format.format(number, toAppendTo, pos);
        }
        @Override
        public Number parse(String source, ParsePosition parsePosition) {
            return format.parse(source, parsePosition);
        }
        @Override
        public AttributedCharacterIterator formatToCharacterIterator(Object obj) {
            AttributedCharacterIterator aci = format.formatToCharacterIterator(obj);
            return convertSeparatorFieldToLiteral(aci);
        }
    };
    
    NumberFormatter formatter = new NumberFormatter(myFormat);
    
    formatter.setAllowsInvalid(false);
    formatter.setOverwriteMode(true);
    JFormattedTextField price = new JFormattedTextField(formatter);
    price.setValue(0.00);
    

    The custom NumberFormat delegates to the original format in almost every regard. The only difference is that the DECIMAL_SEPARATOR attribute gets removed from the character iterator, so the decimal separator will be treated like literal text of the format which can not be changed.

    As a result, you can type straight-forwardly in overwrite mode. However, the handling of a JFormattedTextField in overwrite mode still is tricky. You need to setMinimumIntegerDigits(…) to make writing numbers with more digits easier, but deleting decimal digits will become unintuitive then.