32

It seems that the Spinner control does not update a manually typed-in value until the user explicitly presses enter. So, they could type in a value (not press enter) exit the control, and submit the form, and the value displayed in the spinner is NOT the value of the Spinner, it is the old value.

My idea was to add a listener to the lost focus event, but I can't see a way to gain access to the typed-in value?

spinner.focusedProperty().addListener((observable, oldValue, newValue) -> 
{
    //if focus lost
    if(!newValue)
    {
        //somehow get the text the user typed in?
    }
});

This is odd behavior, it seems to go against the convention of a GUI spinner control.

kleopatra
  • 51,061
  • 28
  • 99
  • 211
James Wierzba
  • 16,176
  • 14
  • 79
  • 120

7 Answers7

36

Unfortunately, Spinner doesn't behave as expected: in most OS, it should commit the edited value on focus lost. Even more unfortunate, it doesn't provide any configuration option to easily make it behave as expected.

So we have to manually commit the value in a listener to the focusedProperty. On the bright side, Spinner already has code doing so - it's private, though, we have to c&p it

/**
 * c&p from Spinner
 */
private <T> void commitEditorText(Spinner<T> spinner) {
    if (!spinner.isEditable()) return;
    String text = spinner.getEditor().getText();
    SpinnerValueFactory<T> valueFactory = spinner.getValueFactory();
    if (valueFactory != null) {
        StringConverter<T> converter = valueFactory.getConverter();
        if (converter != null) {
            T value = converter.fromString(text);
            valueFactory.setValue(value);
        }
    }
}

// useage in client code
spinner.focusedProperty().addListener((s, ov, nv) -> {
    if (nv) return;
    //intuitive method on textField, has no effect, though
    //spinner.getEditor().commitValue(); 
    commitEditorText(spinner);
});

Note that there's a method

textField.commitValue()

which I would have expected to ... well ... commit the value, which has no effect. It's (final!) implemented to update the value of the textFormatter if available. Doesn't work in the Spinner, even if you use a textFormatter for validation. Might be some internal listener missing or the spinner not yet updated to the relatively new api - didn't dig, though.


Update

While playing around a bit more with TextFormatter I noticed that a formatter guarantees to commit on focusLost:

The value is updated when the control loses its focus or it is commited (TextField only)

Which indeed works as documented such that we could add a listener to the formatter's valueProperty to get notified whenever the value is committed:

TextField field = new TextField();
TextFormatter fieldFormatter = new TextFormatter(
      TextFormatter.IDENTITY_STRING_CONVERTER, "initial");
field.setTextFormatter(fieldFormatter);
fieldFormatter.valueProperty().addListener((s, ov, nv) -> {
    // do stuff that needs to be done on commit
} );

Triggers for a commit:

  • user hits ENTER
  • control looses focus
  • field.setText is called programmatically (this is undocumented behaviour!)

Coming back to the spinner: we can use this commit-on-focusLost behaviour of a formatter's value to force a commit on the spinnerFactory's value. Something like

// normal setup of spinner
SpinnerValueFactory factory = new IntegerSpinnerValueFactory(0, 10000, 0);
spinner.setValueFactory(factory);
spinner.setEditable(true);
// hook in a formatter with the same properties as the factory
TextFormatter formatter = new TextFormatter(factory.getConverter(), factory.getValue());
spinner.getEditor().setTextFormatter(formatter);
// bidi-bind the values
factory.valueProperty().bindBidirectional(formatter.valueProperty());

Note that editing (either typing or programmatically replacing/appending/pasting text) does not trigger a commit - so this cannot be used if commit-on-text-change is needed.

Community
  • 1
  • 1
kleopatra
  • 51,061
  • 28
  • 99
  • 211
  • For those who come to this answer in search of why their spinner isn't updating despite pressing enter: you're probably using a ChangeListener - it activates only if there was a real change in the value, which JavaFX sometimes seems to get wrong. Try using an InvalidationListener. – Ondrej Skopek Mar 08 '17 at 16:02
  • @kleopatra I hope the site notifies you about this comment, I have a question about [the answer below from Sergio](https://stackoverflow.com/a/39380146/8957293). Do you have any thoughts on it? It seems such a short and sweet solution that it feels too good to be true. It's only a year newer than yours, if it is that good I would have expected it to overtake the score of your answer by now. Is there an issue with Sergio's answer that needs to be noted? Or are people stopping scrolling as soon as they get to your solution? I don't think it worked when I tried to tag you under that answer. – pateksan Jan 19 '21 at 12:11
  • @kleopatra, thanks. I do have another question (sorry if I'm dragging you into an old problem): I was a bit confused when you said _we could just as well reject inc/dec while editing_? Rejecting inc/dec doesn't seem related to the OPs question. Is this a side effect of Sergio's code? Or another means of achieving the OPs original objective? Also, I'm still newish but your comment (the one above this, just an hour ago) might be of more use to everyone if you put it under Sergio's answer instead of yours - I'm sorry if it's rude to suggest this to someone with 2^8 more rep. – pateksan Jan 19 '21 at 13:31
  • @pateksan _Rejecting inc/dec_ was an example of how else inc could be implemented - what's not specified doesn't exist :) No, I'm not going to comment the other answer, said enough. – kleopatra Jan 19 '21 at 13:42
  • @kleopatra OK, _I think_ I get it. A question about your answer: it relies on a bidirectional binding, something [I have been struggling with](https://stackoverflow.com/q/65462905/8957293) because I can't fully understand what exactly does/doesn't [provide a strong enough reference](https://docs.oracle.com/javase/8/javafx/api/javafx/beans/property/IntegerProperty.html#bindBidirectional-javafx.beans.property.Property-). I will work that out one day but in the meantime, is the bidirectional binding at the bottom of your answer safe from garbage collection? Hope you see what I mean. Thanks again. – pateksan Jan 19 '21 at 15:04
  • 1
    @pateksan short (and last) answer: yes - sry, this is getting just too much off-topic - to learn all about references, work through a tutorial on java basics related to weak/strong references and apply what you learned to this context (f.i. by thinking through the path of references here, writing tests, ask a question when stuck .. :) Have fun! – kleopatra Jan 19 '21 at 15:37
33

@kleopatra headed to a right direction, but the copy-paste solution feels awkward and the TextFormatter-based one did not work for me at all. So here's a shorter one, which forces Spinner to call it's private commitEditorText() as desired:

spinner.focusedProperty().addListener((observable, oldValue, newValue) -> {
  if (!newValue) {
    spinner.increment(0); // won't change value, but will commit editor
  }
});
Sergio
  • 1,040
  • 13
  • 14
  • Thank you! Works like a charm. – AvaLanCS Dec 18 '16 at 17:14
  • nice answer. Worked – Roger Dec 22 '17 at 08:54
  • 1
    @kleopatra and anyone else: if this is such a short and sweet solution, why has it still not overtaken the score of kleopatra's answer? Both these answers are now virtually the same age of 5ish years. Is there an issue with this answer that users need to be aware of? Are people just not scrolling down far enough? – pateksan Jan 16 '21 at 17:23
4

This is standard behavior for the control according to the documentation:

The editable property is used to specify whether user input is able to be typed into the Spinner editor. If editable is true, user input will be received once the user types and presses the Enter key. At this point the input is passed to the SpinnerValueFactory converter StringConverter.fromString(String) method. The returned value from this call (of type T) is then sent to the SpinnerValueFactory.setValue(Object) method. If the value is valid, it will remain as the value. If it is invalid, the value factory will need to react accordingly and back out this change.

Perhaps you could use a keyboard event to listen to and call the edit commit on the control as you go.

purring pigeon
  • 4,141
  • 5
  • 35
  • 68
  • You could try this.... spinner.getEditor().textProperty().addListener((observable, oldValue, newValue) -> { commitEditorText(); }); – purring pigeon Sep 02 '15 at 14:25
3

Here is an improved variant of Sergio's solution.

The initialize method will attach Sergio's code to all Spinners in the controller.

public void initialize(URL location, ResourceBundle resources) {
    for (Field field : getClass().getDeclaredFields()) {
        try {
            Object obj = field.get(this);
            if (obj != null && obj instanceof Spinner)
                ((Spinner) obj).focusedProperty().addListener((observable, oldValue, newValue) -> {
                    if (!newValue) {
                        ((Spinner) obj).increment(0);
                    }
                });
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
    }
}
Robert
  • 434
  • 1
  • 4
  • 8
0

Using a listener should work. You can get access to the typed in value through the spinner's editor:

spinner.getEditor().getText();
Amber
  • 2,413
  • 1
  • 15
  • 20
  • Maybe you forgot the part that explains how to do that? – Mateus Viccari May 17 '17 at 22:05
  • Original question shows how to add the listener, the part OP wasn't sure about was "a way to gain access to the typed-in value" which is shown in my answer. – Amber Jan 08 '18 at 15:38
0

I use an alternate approach - update it live while typing. This is my current implementation:

getEditor().textProperty().addListener { _, _, nv ->
    // let the user clear the field without complaining
    if(nv.isNotEmpty()) {
        Double newValue = getValue()
        try {
            newValue = getValueFactory().getConverter().fromString(nv)
        } catch (Exception e) { /* user typed an illegal character */ } 
        getValueFactory().setValue(newValue)
    }
xeruf
  • 2,602
  • 1
  • 25
  • 48
0

I used this approach

public class SpinnerFocusListener<T> implements ChangeListener<Boolean> {
   private Spinner<T> spinner;

   public SpinnerFocusListener(Spinner<T> spinner) {
       super();
       this.spinner = spinner;      
       this.spinner.getEditor().focusedProperty().addListener(this);
   }

   @Override
   public void changed(ObservableValue<? extends Boolean> observable, 
                           Boolean oldValue, Boolean newValue) {        
        StringConverter<T>converter=spinner.getValueFactory().getConverter();
        TextField editor=spinner.getEditor();
        String text=editor.getText();
        try {
            T value=converter.fromString(text);
            spinner.getValueFactory().setValue(value);
        }catch(Throwable ex) {
            editor.setText(converter.toString(spinner.getValue()));
        }   
    }
}
Telcontar
  • 4,794
  • 7
  • 31
  • 39