2

I would like to format a float number as a percent-value with JFormattedTextField that allows inputs from 0 to 100 percent (converted to 0.0f-1.0f), always shows the percent sign and disallows any invalid characters.

Now I have experimented a bit with NumberFormat.getPercentInstance() and the NumberFormatter attributes but without success.

Is there a way to create a JFormattedTextField that obeys to these rules with the standard classes? Or do I have to implement my own NumberFormatter?

That's what I have so far (no way to input 100%, entering a 0 breaks it completly):

public class MaskFormatterTest {
    public static void main(String[] args) throws Exception {
        JFrame frame = new JFrame("Test");
        frame.setLayout(new BorderLayout());
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

        NumberFormat format = NumberFormat.getPercentInstance();
        NumberFormatter formatter = new NumberFormatter(format);
        formatter.setMaximum(1.0f);
        formatter.setMinimum(0.0f);
        formatter.setAllowsInvalid(false);
        formatter.setOverwriteMode(true);
        JFormattedTextField tf = new JFormattedTextField(formatter);
        tf.setColumns(20);
        tf.setValue(0.56f);

        frame.add(tf);
        frame.pack();
        frame.setVisible(true);
    }
}
easwee
  • 15,757
  • 24
  • 60
  • 83
Roland Schneider
  • 3,615
  • 3
  • 32
  • 43
  • What's your default locale and did you enter the text according to that locale? – Thomas Sep 27 '11 at 13:47
  • As a side note: you might use a JSpinner with a SpinnerNumberModel instead - that would be more user-friendly as well. – Thomas Sep 27 '11 at 13:55
  • My default Locale is DE_DE, but I wonder how this should change the way I enter the text since the Textfield shall show only integer values without grouping characters. JSpinner is a good idea - I definitely gonna try it. – Roland Schneider Sep 27 '11 at 16:07

3 Answers3

7

Ok, I've made it. The solution is far from simple, but at least it does exactly what I want. Except for returning doubles instead of floats. One major limitation is that it does not allow fraction digits, but for now I can live with that.

import java.awt.BorderLayout;
import java.text.NumberFormat;
import java.text.ParseException;

import javax.swing.JComponent;
import javax.swing.JFormattedTextField;
import javax.swing.JSpinner;
import javax.swing.SpinnerNumberModel;
import javax.swing.text.AttributeSet;
import javax.swing.text.BadLocationException;
import javax.swing.text.DefaultFormatterFactory;
import javax.swing.text.DocumentFilter;
import javax.swing.text.NavigationFilter;
import javax.swing.text.NumberFormatter;
import javax.swing.text.SimpleAttributeSet;
import javax.swing.text.Position.Bias;

public class JPercentField extends JComponent {

    private static final double MIN_VALUE = 0.0d;
    private static final double MAX_VALUE = 1.0d;
    private static final double STEP_SIZE = 0.01d;

    private static final long serialVersionUID = -779235114254706347L;

    private JSpinner spinner;

    public JPercentField() {
        initComponents();
        initLayout();
        spinner.setValue(MIN_VALUE);
    }

    private void initComponents() {
        SpinnerNumberModel model = new SpinnerNumberModel(MIN_VALUE, MIN_VALUE, MAX_VALUE, STEP_SIZE);
        spinner = new JSpinner(model);
        initSpinnerTextField();
    }

    private void initSpinnerTextField() {
        DocumentFilter digitOnlyFilter = new PercentDocumentFilter(getMaximumDigits());
        NavigationFilter navigationFilter = new BlockLastCharacterNavigationFilter(getTextField());
        getTextField().setFormatterFactory(
                new DefaultFormatterFactory(new PercentNumberFormatter(createPercentFormat(), navigationFilter,
                        digitOnlyFilter)));
        getTextField().setColumns(6);
    }

    private int getMaximumDigits() {
        return Integer.toString((int) MAX_VALUE * 100).length();
    }

    private JFormattedTextField getTextField() {
        JSpinner.NumberEditor jsEditor = (JSpinner.NumberEditor) spinner.getEditor();
        JFormattedTextField textField = jsEditor.getTextField();
        return textField;
    }

    private NumberFormat createPercentFormat() {
        NumberFormat format = NumberFormat.getPercentInstance();
        format.setGroupingUsed(false);
        format.setMaximumIntegerDigits(getMaximumDigits());
        format.setMaximumFractionDigits(0);
        return format;
    }

    private void initLayout() {
        setLayout(new BorderLayout());
        add(spinner, BorderLayout.CENTER);
    }

    public double getPercent() {
        return (Double) spinner.getValue();
    }

    public void setPercent(double percent) {
        spinner.setValue(percent);
    }

    private static class PercentNumberFormatter extends NumberFormatter {

        private static final long serialVersionUID = -1172071312046039349L;

        private final NavigationFilter navigationFilter;
        private final DocumentFilter digitOnlyFilter;

        private PercentNumberFormatter(NumberFormat format, NavigationFilter navigationFilter,
                DocumentFilter digitOnlyFilter) {
            super(format);
            this.navigationFilter = navigationFilter;
            this.digitOnlyFilter = digitOnlyFilter;
        }

        @Override
        protected NavigationFilter getNavigationFilter() {
            return navigationFilter;
        }

        @Override
        protected DocumentFilter getDocumentFilter() {
            return digitOnlyFilter;
        }

        @Override
        public Class<?> getValueClass() {
            return Double.class;
        }

        @Override
        public Object stringToValue(String text) throws ParseException {
            Double value = (Double) super.stringToValue(text);
            return Math.max(MIN_VALUE, Math.min(MAX_VALUE, value));
        }
    }

    /**
     * NavigationFilter that avoids navigating beyond the percent sign.
     */
    private static class BlockLastCharacterNavigationFilter extends NavigationFilter {

        private JFormattedTextField textField;

        private BlockLastCharacterNavigationFilter(JFormattedTextField textField) {
            this.textField = textField;
        }

        @Override
        public void setDot(FilterBypass fb, int dot, Bias bias) {
            super.setDot(fb, correctDot(fb, dot), bias);
        }

        @Override
        public void moveDot(FilterBypass fb, int dot, Bias bias) {
            super.moveDot(fb, correctDot(fb, dot), bias);
        }

        private int correctDot(FilterBypass fb, int dot) {
            // Avoid selecting the percent sign
            int lastDot = Math.max(0, textField.getText().length() - 1);
            return dot > lastDot ? lastDot : dot;
        }
    }

    private static class PercentDocumentFilter extends DocumentFilter {

        private int maxiumDigits;

        public PercentDocumentFilter(int maxiumDigits) {
            super();
            this.maxiumDigits = maxiumDigits;
        }

        @Override
        public void insertString(FilterBypass fb, int offset, String text, AttributeSet attrs)
                throws BadLocationException {
            // Mapping an insert as a replace without removing
            replace(fb, offset, 0, text, attrs);
        }

        @Override
        public void remove(FilterBypass fb, int offset, int length) throws BadLocationException {
            // Mapping a remove as a replace without inserting
            replace(fb, offset, length, "", SimpleAttributeSet.EMPTY);
        }

        @Override
        public void replace(FilterBypass fb, int offset, int length, String text, AttributeSet attrs)
                throws BadLocationException {
            int replaceLength = correctReplaceLength(fb, offset, length);
            String cleanInput = truncateInputString(fb, filterDigits(text), replaceLength);
            super.replace(fb, offset, replaceLength, cleanInput, attrs);
        }

        /**
         * Removes all non-digit characters
         */
        private String filterDigits(String text) throws BadLocationException {
            StringBuilder sb = new StringBuilder(text);
            for (int i = 0, n = sb.length(); i < n; i++) {
                if (!Character.isDigit(text.charAt(i))) {
                    sb.deleteCharAt(i);
                }
            }
            return sb.toString();
        }

        /**
         * Removes all characters with which the resulting text would exceed the maximum number of digits
         */
        private String truncateInputString(FilterBypass fb, String filterDigits, int replaceLength) {
            StringBuilder sb = new StringBuilder(filterDigits);
            int currentTextLength = fb.getDocument().getLength() - replaceLength - 1;
            for (int i = 0; i < sb.length() && currentTextLength + sb.length() > maxiumDigits; i++) {
                sb.deleteCharAt(i);
            }
            return sb.toString();
        }

        private int correctReplaceLength(FilterBypass fb, int offset, int length) {
            if (offset + length >= fb.getDocument().getLength()) {
                // Don't delete the percent sign
                return offset + length - fb.getDocument().getLength();
            }
            return length;
        }
    }

}
Roland Schneider
  • 3,615
  • 3
  • 32
  • 43
2

1) consider using JSpinner instead of JFormattedTextField because there you can set SpinnerNumberModel for initial values

from API

Integer value = new Integer(50); 
Integer min = new Integer(0);
Integer max = new Integer(100); 
Integer step = new Integer(1);

and with simple hack for JSpinner (with SpinnerNumberModel) it doesn't allows another input as Digits, otherwise is there possible input any of Chars

2) for JFormattedTextField you have to implements

  • DocumentListener
  • Document

and of both cases for JFormattedTextField you have to write workaround for catch if value is less or more than required range ...

EDIT:

.

enter image description here

.

not true at all, :-) you are so far from ... simple wrong :-), there is small mistake with your result, please look at this code

import java.awt.BorderLayout;
import java.text.NumberFormat;
import javax.swing.*;
import javax.swing.text.*;

public class TestDigitsOnlySpinner {

    public static void main(String... args) {
        SwingUtilities.invokeLater(new Runnable() {

            public void run() {
                JFrame frame = new JFrame("enter digit");
                JSpinner jspinner = makeDigitsOnlySpinnerUsingDocumentFilter();
                frame.getContentPane().add(jspinner, BorderLayout.CENTER);
                frame.getContentPane().add(new JButton("just another widget"), BorderLayout.SOUTH);
                frame.pack();
                frame.setVisible(true);
            }

            private JSpinner makeDigitsOnlySpinnerUsingDocumentFilter() {
                JSpinner spinner = new JSpinner(new SpinnerNumberModel());
                JSpinner.NumberEditor jsEditor = (JSpinner.NumberEditor) spinner.getEditor();
                JFormattedTextField textField = jsEditor.getTextField();
                final DocumentFilter digitOnlyFilter = new DocumentFilter() {

                    @Override
                    public void insertString(FilterBypass fb, int offset, String string, AttributeSet attr) throws BadLocationException {
                        if (stringContainsOnlyDigits(string)) {
                            super.insertString(fb, offset, string, attr);
                        }
                    }

                    @Override
                    public void remove(FilterBypass fb, int offset, int length) throws BadLocationException {
                        super.remove(fb, offset, length);
                    }

                    @Override
                    public void replace(FilterBypass fb, int offset, int length, String text, AttributeSet attrs) throws BadLocationException {
                        if (stringContainsOnlyDigits(text)) {
                            super.replace(fb, offset, length, text, attrs);
                        }
                    }

                    private boolean stringContainsOnlyDigits(String text) {
                        for (int i = 0; i < text.length(); i++) {
                            if (!Character.isDigit(text.charAt(i))) {
                                return false;
                            }
                        }
                        return true;
                    }
                };
                /*NumberFormat format = NumberFormat.getIntegerInstance();
                format.setGroupingUsed(false);// or add the group chars to the filter
                NumberFormat format = NumberFormat.getInstance();*/

                NumberFormat format = NumberFormat.getPercentInstance();
                format.setGroupingUsed(false);
                format.setGroupingUsed(true);// or add the group chars to the filter
                format.setMaximumIntegerDigits(10);
                format.setMaximumFractionDigits(2);
                format.setMinimumFractionDigits(5);
                textField.setFormatterFactory(new DefaultFormatterFactory(new InternationalFormatter(format) {

                    private static final long serialVersionUID = 1L;

                    @Override
                    protected DocumentFilter getDocumentFilter() {
                        return digitOnlyFilter;
                    }
                }));
                return spinner;
            }
        });
    }
}
mKorbel
  • 109,525
  • 20
  • 134
  • 319
  • Obviously it is not possible to get a component which behaves like I described in my question. At least not with standard Swing components or a few simple tricks. I am now using a JSpinner with a range from 0 - 100 (as you suggested), convert the values to float manually and ignore the absence of the percent sign. With setting allowsInvalid to false and overwriteMode to true in the NumberFormatter of the embedded JFormattedTextField the behavior is ok for my needs. – Roland Schneider Sep 28 '11 at 10:00
  • Thank you. However there are still some issues with this code. When the percent or decimal separator sign is removed, there is no way to get them back. Both can be fixed in the DocumentFilter with a few more ifs. Quite a lot of effort for such a simple task. ;) – Roland Schneider Sep 28 '11 at 13:21
  • @Roland no idea ... , please post here (edit your post or put as new Answer, whatewer) runnable code that shows your issue(s), maybe you made there some mistake, or maybe I something missed .... – mKorbel Sep 28 '11 at 13:28
1

Imho https://docs.oracle.com/javase/tutorial/uiswing/components/formattedtextfield.html gives a pretty good example (see section "Specifying Formatters and Using Formatter Factories").

The key is to use a Percent Format to display values and a custom NumberFormatter to edit values. This approach also allows the use of fraction digits.

// create a format for displaying percentages (with %-sign)
NumberFormat percentDisplayFormat = NumberFormat.getPercentInstance();

// create a format for editing percentages (without %-sign)
NumberFormat percentEditFormat = NumberFormat.getNumberInstance();

// create a formatter for editing percentages - input will be transformed to percentages (eg. 50 -> 0.5)
NumberFormatter percentEditFormatter = new NumberFormatter(percentEditFormat) {
    private static final long serialVersionUID = 1L;

    @Override
    public String valueToString(Object o) throws ParseException {
        Number number = (Number) o;
        if (number != null) {
            double d = number.doubleValue() * 100.0;
            number = new Double(d);
        }
        return super.valueToString(number);
    }

    @Override
    public Object stringToValue(String s) throws ParseException {
        Number number = (Number) super.stringToValue(s);
        if (number != null) {
            double d = number.doubleValue() / 100.0;
            number = new Double(d);
        }
        return number;
    }
};

// set allowed range
percentEditFormatter.setMinimum(0D);
percentEditFormatter.setMaximum(100D);

// create JFormattedTextField
JFormattedTextField field = new JFormattedTextField(
    new DefaultFormatterFactory(
        new NumberFormatter(percentDisplayFormat),
        new NumberFormatter(percentDisplayFormat),
        percentEditFormatter));
domids
  • 515
  • 5
  • 21