11

I have an issue where an editable JavaFX 8 Spinner causes an uncaught NullPointerException if one clears the editor text and commits and then clicks either the increment or decrement button. This is j8u60 j8u77. With some luck the increment/decrement button will get stuck in depressed state and the NPE's keep flowing locking up the application.

The following code reproduces the issue for me:

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.Spinner;
import javafx.scene.control.SpinnerValueFactory;
import javafx.scene.control.SpinnerValueFactory.IntegerSpinnerValueFactory;
import javafx.stage.Stage;

public class Test extends Application {
    public static void main(String[] args) {
        launch(args);
    }

    @Override
    public void start(Stage aPrimaryStage) throws Exception {
        IntegerSpinnerValueFactory valueFactory = new IntegerSpinnerValueFactory(0, 10);
        Spinner<Integer> spinner = new Spinner<>(valueFactory);
        spinner.setEditable(true);
        aPrimaryStage.setScene(new Scene(spinner));
        aPrimaryStage.show();
    }
}

Run it, clear the text, press enter (NullPointerException), clicking either increment or decrement button will now also cause NPE.

Can any one confirm that this is a JavaFX bug and suggest a workaround?

Edit: The exception stack trace

Exception in thread "JavaFX Application Thread" java.lang.NullPointerException
    at javafx.scene.control.SpinnerValueFactory$IntegerSpinnerValueFactory.lambda$new$215(SpinnerValueFactory.java:475)
    at com.sun.javafx.binding.ExpressionHelper$Generic.fireValueChangedEvent(ExpressionHelper.java:361)
    at com.sun.javafx.binding.ExpressionHelper.fireValueChangedEvent(ExpressionHelper.java:81)
    at javafx.beans.property.ObjectPropertyBase.fireValueChangedEvent(ObjectPropertyBase.java:105)
    at javafx.beans.property.ObjectPropertyBase.markInvalid(ObjectPropertyBase.java:112)
    at javafx.beans.property.ObjectPropertyBase.set(ObjectPropertyBase.java:146)
    at javafx.scene.control.SpinnerValueFactory.setValue(SpinnerValueFactory.java:150)
    at javafx.scene.control.Spinner.lambda$new$210(Spinner.java:139)
    at com.sun.javafx.event.CompositeEventHandler.dispatchBubblingEvent(CompositeEventHandler.java:86)
    at com.sun.javafx.event.EventHandlerManager.dispatchBubblingEvent(EventHandlerManager.java:238)
    at com.sun.javafx.event.EventHandlerManager.dispatchBubblingEvent(EventHandlerManager.java:191)
    at com.sun.javafx.event.CompositeEventDispatcher.dispatchBubblingEvent(CompositeEventDispatcher.java:59)
    at com.sun.javafx.event.BasicEventDispatcher.dispatchEvent(BasicEventDispatcher.java:58)
    at com.sun.javafx.event.EventDispatchChainImpl.dispatchEvent(EventDispatchChainImpl.java:114)
    at com.sun.javafx.event.BasicEventDispatcher.dispatchEvent(BasicEventDispatcher.java:56)
    at com.sun.javafx.event.EventDispatchChainImpl.dispatchEvent(EventDispatchChainImpl.java:114)
    at com.sun.javafx.event.BasicEventDispatcher.dispatchEvent(BasicEventDispatcher.java:56)
    at com.sun.javafx.event.EventDispatchChainImpl.dispatchEvent(EventDispatchChainImpl.java:114)
    at com.sun.javafx.event.BasicEventDispatcher.dispatchEvent(BasicEventDispatcher.java:56)
    at com.sun.javafx.event.EventDispatchChainImpl.dispatchEvent(EventDispatchChainImpl.java:114)
    at com.sun.javafx.event.EventUtil.fireEventImpl(EventUtil.java:74)
    at com.sun.javafx.event.EventUtil.fireEvent(EventUtil.java:49)
    at javafx.event.Event.fireEvent(Event.java:198)
    at javafx.scene.Node.fireEvent(Node.java:8411)
    at com.sun.javafx.scene.control.behavior.TextFieldBehavior.fire(TextFieldBehavior.java:179)
    at com.sun.javafx.scene.control.behavior.TextInputControlBehavior.callAction(TextInputControlBehavior.java:178)
    at com.sun.javafx.scene.control.behavior.BehaviorBase.callActionForEvent(BehaviorBase.java:218)
    at com.sun.javafx.scene.control.behavior.TextInputControlBehavior.callActionForEvent(TextInputControlBehavior.java:127)
    at com.sun.javafx.scene.control.behavior.BehaviorBase.lambda$new$74(BehaviorBase.java:135)
    at com.sun.javafx.event.CompositeEventHandler$NormalEventHandlerRecord.handleBubblingEvent(CompositeEventHandler.java:218)
    at com.sun.javafx.event.CompositeEventHandler.dispatchBubblingEvent(CompositeEventHandler.java:80)
    at com.sun.javafx.event.EventHandlerManager.dispatchBubblingEvent(EventHandlerManager.java:238)
    at com.sun.javafx.event.EventHandlerManager.dispatchBubblingEvent(EventHandlerManager.java:191)
    at com.sun.javafx.event.CompositeEventDispatcher.dispatchBubblingEvent(CompositeEventDispatcher.java:59)
    at com.sun.javafx.event.BasicEventDispatcher.dispatchEvent(BasicEventDispatcher.java:58)
    at com.sun.javafx.event.EventDispatchChainImpl.dispatchEvent(EventDispatchChainImpl.java:114)
    at com.sun.javafx.event.BasicEventDispatcher.dispatchEvent(BasicEventDispatcher.java:56)
    at com.sun.javafx.event.EventDispatchChainImpl.dispatchEvent(EventDispatchChainImpl.java:114)
    at com.sun.javafx.event.BasicEventDispatcher.dispatchEvent(BasicEventDispatcher.java:56)
    at com.sun.javafx.event.EventDispatchChainImpl.dispatchEvent(EventDispatchChainImpl.java:114)
    at com.sun.javafx.event.BasicEventDispatcher.dispatchEvent(BasicEventDispatcher.java:56)
    at com.sun.javafx.event.EventDispatchChainImpl.dispatchEvent(EventDispatchChainImpl.java:114)
    at com.sun.javafx.event.EventUtil.fireEventImpl(EventUtil.java:74)
    at com.sun.javafx.event.EventUtil.fireEvent(EventUtil.java:49)
    at javafx.event.Event.fireEvent(Event.java:198)
    at javafx.scene.Node.fireEvent(Node.java:8411)
    at com.sun.javafx.scene.control.skin.SpinnerSkin.lambda$new$473(SpinnerSkin.java:151)
    at com.sun.javafx.event.CompositeEventHandler$NormalEventFilterRecord.handleCapturingEvent(CompositeEventHandler.java:282)
    at com.sun.javafx.event.CompositeEventHandler.dispatchCapturingEvent(CompositeEventHandler.java:98)
    at com.sun.javafx.event.EventHandlerManager.dispatchCapturingEvent(EventHandlerManager.java:223)
    at com.sun.javafx.event.EventHandlerManager.dispatchCapturingEvent(EventHandlerManager.java:180)
    at com.sun.javafx.event.CompositeEventDispatcher.dispatchCapturingEvent(CompositeEventDispatcher.java:43)
    at com.sun.javafx.event.BasicEventDispatcher.dispatchEvent(BasicEventDispatcher.java:52)
    at com.sun.javafx.event.EventDispatchChainImpl.dispatchEvent(EventDispatchChainImpl.java:114)
    at com.sun.javafx.event.BasicEventDispatcher.dispatchEvent(BasicEventDispatcher.java:56)
    at com.sun.javafx.event.EventDispatchChainImpl.dispatchEvent(EventDispatchChainImpl.java:114)
    at com.sun.javafx.event.BasicEventDispatcher.dispatchEvent(BasicEventDispatcher.java:56)
    at com.sun.javafx.event.EventDispatchChainImpl.dispatchEvent(EventDispatchChainImpl.java:114)
    at com.sun.javafx.event.EventUtil.fireEventImpl(EventUtil.java:74)
    at com.sun.javafx.event.EventUtil.fireEvent(EventUtil.java:54)
    at javafx.event.Event.fireEvent(Event.java:198)
    at javafx.scene.Scene$KeyHandler.process(Scene.java:3964)
    at javafx.scene.Scene$KeyHandler.access$1800(Scene.java:3910)
    at javafx.scene.Scene.impl_processKeyEvent(Scene.java:2040)
    at javafx.scene.Scene$ScenePeerListener.keyEvent(Scene.java:2501)
    at com.sun.javafx.tk.quantum.GlassViewEventHandler$KeyEventNotification.run(GlassViewEventHandler.java:197)
    at com.sun.javafx.tk.quantum.GlassViewEventHandler$KeyEventNotification.run(GlassViewEventHandler.java:147)
    at java.security.AccessController.doPrivileged(Native Method)
    at com.sun.javafx.tk.quantum.GlassViewEventHandler.lambda$handleKeyEvent$353(GlassViewEventHandler.java:228)
    at com.sun.javafx.tk.quantum.QuantumToolkit.runWithoutRenderLock(QuantumToolkit.java:389)
    at com.sun.javafx.tk.quantum.GlassViewEventHandler.handleKeyEvent(GlassViewEventHandler.java:227)
    at com.sun.glass.ui.View.handleKeyEvent(View.java:546)
    at com.sun.glass.ui.View.notifyKey(View.java:966)
    at com.sun.glass.ui.win.WinApplication._runLoop(Native Method)
    at com.sun.glass.ui.win.WinApplication.lambda$null$148(WinApplication.java:191)
    at java.lang.Thread.run(Thread.java:745)
bruno
  • 2,213
  • 1
  • 19
  • 31
Emily L.
  • 5,673
  • 2
  • 40
  • 60
  • It's not a bug. It's an Integer spinner and it's a non integer value that's been placed inside it. It can only spin the values if they are valid integers. So, this would be expected behavior. You'll need to handle this potential exception in your code. Simply set Editable to false and it removes the ability to change that value. – ManoDestra Apr 11 '16 at 13:25
  • 3
    @ManoDestra Have a look at the stack trace. There is no point from which to catch it. It's completely internal to JavaFX. – VGR Apr 11 '16 at 13:26
  • Because the above code does nothing to handle the control's events. And yet it's still be set to be editable. If you don't want the exception to occur, either set editable to false, or handle the controls events, if you allow it to be edited. Simple :) – ManoDestra Apr 11 '16 at 13:30
  • @ManoDestra please provide an example of how you would do this. I agree with VGR here I don't think there is a reasonable point where it can be handled. – Emily L. Apr 11 '16 at 13:32
  • 1
    @ManoDestra This is a bug. The `converter` property of the `IntegerSpinnerValueFactory` is supposed to handle this. – James_D Apr 11 '16 at 13:37
  • Added code to handle it in my answer :) – ManoDestra Apr 11 '16 at 13:54

5 Answers5

6

I had a rummage through the JDK source.

The NPE is thrown from if (newValue < getMin()) { in the listener lambda here:

javafx.scene.control.SpinnerValueFactory.java

    public IntegerSpinnerValueFactory(@NamedArg("min") int min,
                                      @NamedArg("max") int max,
                                      @NamedArg("initialValue") int initialValue,
                                      @NamedArg("amountToStepBy") int amountToStepBy) {
        setMin(min);
        setMax(max);
        setAmountToStepBy(amountToStepBy);
        setConverter(new IntegerStringConverter());

        valueProperty().addListener((o, oldValue, newValue) -> {
            // when the value is set, we need to react to ensure it is a
            // valid value (and if not, blow up appropriately)
            if (newValue < getMin()) { 
                setValue(getMin());
            } else if (newValue > getMax()) {
                setValue(getMax());
            }
        });
        setValue(initialValue >= min && initialValue <= max ? initialValue : min);
    }

presumably newValue is null and the auto unboxing of null throws NPE. As the input comes from the editor, I suspect the IntegerStringConverter which is the default converter.

Looking at the implementation here:

javafx.util.converter.IntegerStringConverter

public class IntegerStringConverter extends StringConverter<Integer> {
    /** {@inheritDoc} */
    @Override public Integer fromString(String value) {
        // If the specified value is null or zero-length, return null
        if (value == null) {
            return null;
        }

        value = value.trim();

        if (value.length() < 1) {
            return null;
        }

        return Integer.valueOf(value);
    }

    /** {@inheritDoc} */
    @Override public String toString(Integer value) {
        // If the specified value is null, return a zero-length String
        if (value == null) {
            return "";
        }

        return (Integer.toString(((Integer)value).intValue()));
    }
}

We see that it will happily return null for the empty string, which is kind of reasonable given that there exists no valid value for the input.

Tracing up the call stack I find where the value is coming from:

javafx.scene.control.Spinner

public Spinner() {
    getStyleClass().add(DEFAULT_STYLE_CLASS);
    setAccessibleRole(AccessibleRole.SPINNER);

    getEditor().setOnAction(action -> {
        String text = getEditor().getText();
        SpinnerValueFactory<T> valueFactory = getValueFactory();
        if (valueFactory != null) {
            StringConverter<T> converter = valueFactory.getConverter();
            if (converter != null) {
                T value = converter.fromString(text);
                valueFactory.setValue(value);
            }
        }
    });

The value is set with the value obtained from the converter T value = converter.fromString(text); which presumably is null. At this point I believe that the spinner class should check that value is not null and if it is restore the previous value to the editor.

I am now fairly sure that this is a bug. Moreover I don't think that a work around with a converter that never returns null is going to work properly as it will only mask the problem and what value should be returned when the value cannot be converted?

Edit: Workaround

Replacing the onAction of the spinner editor to reject invalid input with a "return to valid" policy fixes the issue:

public static <T> void fixSpinner2(Spinner<T> aSpinner) {
    aSpinner.getEditor().setOnAction(action -> {
        String text = aSpinner.getEditor().getText();
        SpinnerValueFactory<T> factory = aSpinner.getValueFactory();
        if (factory != null) {
            StringConverter<T> converter = factory.getConverter();
            if (converter != null) {
                T value = converter.fromString(text);
                if (null != value) {
                    factory.setValue(value);
                }
                else {
                    aSpinner.getEditor().setText(converter.toString(factory.getValue()));
                }
            }
        }
        action.consume();
    });
}

As opposed to a listener on the valueProperty this avoids triggering other listeners with invalid data. However this highlights another issue in the spinner class. While the above fixes the issue by returning to a valid value on pressing enter. Erasing the input without committing (pressing enter) and then pressing increment or decrement will cause the same NPE but with slightly different call stack.

Cause:

public void increment(int steps) {
    SpinnerValueFactory<T> valueFactory = getValueFactory();
    if (valueFactory == null) {
        throw new IllegalStateException("Can't increment Spinner with a null SpinnerValueFactory");
    }
    commitEditorText();
    valueFactory.increment(steps);
}

Decrement is similar, both call into commitEditorText below:

private void commitEditorText() {
    if (!isEditable()) return;
    String text = getEditor().getText();
    SpinnerValueFactory<T> valueFactory = getValueFactory();
    if (valueFactory != null) {
        StringConverter<T> converter = valueFactory.getConverter();
        if (converter != null) {
            T value = converter.fromString(text);
            valueFactory.setValue(value);
        }
    }
}

Notice the copy-paste from the onAction in the constructor:

    getEditor().setOnAction(action -> {
        String text = getEditor().getText();
        SpinnerValueFactory<T> valueFactory = getValueFactory();
        if (valueFactory != null) {
            StringConverter<T> converter = valueFactory.getConverter();
            if (converter != null) {
                T value = converter.fromString(text);
                valueFactory.setValue(value);
            }
        }
    });

I believe that commitEditorText should be changed to trigger onAction on the editor instead like so:

private void commitEditorText() {
    if (!isEditable()) return;
    getEditor().getOnAction().handle(new ActionEvent(this, this));
}

then the behavior would be consistent and give the editor a chance to handle the input before it goes to the value factory.

Community
  • 1
  • 1
Emily L.
  • 5,673
  • 2
  • 40
  • 60
6

This is correct and expected behavior for an Integer based Spinner control.

You should either set its Editable property to false, if you do not wish users to edit the values set via the Factory.

Or you should be handling the event raised by the spinner's value property.

Here's a simple example of how to do so:

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.Spinner;
import javafx.scene.control.SpinnerValueFactory;
import javafx.scene.control.SpinnerValueFactory.IntegerSpinnerValueFactory;
import javafx.stage.Stage;

import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;

public class Spin extends Application {
    Spinner<Integer> spinner;

    public static void main(String[] args) {
        launch(args);
    }

    @Override
    public void start(Stage aPrimaryStage) throws Exception {
        IntegerSpinnerValueFactory valueFactory = new IntegerSpinnerValueFactory(0, 10);
        spinner = new Spinner<>(valueFactory);
        spinner.setEditable(true);
        spinner.valueProperty().addListener((observableValue, oldValue, newValue) -> handleSpin(observableValue, oldValue, newValue));

        aPrimaryStage.setScene(new Scene(spinner));
        aPrimaryStage.show();
    }

    private void handleSpin(ObservableValue<?> observableValue, Number oldValue, Number newValue) {
        try {
            if (newValue == null) {
                spinner.getValueFactory().setValue((int)oldValue);
            }
        } catch (Exception e) {
            System.out.println(e.getMessage());
        }
    }
}

This may also assist you, if you wish to use a converter class to help in handling the changes more comprehensively.

See also the official documentation on the setEditable method;

Community
  • 1
  • 1
ManoDestra
  • 6,325
  • 6
  • 26
  • 50
  • 2
    I actually interpret the documentation you linked somewhat differently; specifically that the value factory should veto the change if it's invalid. The problem with solutions that use listeners to revert a change to an invalid value is that other listeners to the value will observe the change to the invalid value and then the change back. This breaks the semantics of the control, and those listeners have to be written to handle (probably ignore) that case. – James_D Apr 11 '16 at 13:58
  • Possibly. I agree, but this is how the control operates. You don't have to revert the value, of course. This was just an example to highlight that the exception caused by invalid integer input can be handled and you can then choose how to handle it. The reversion of the value is purely for demonstration purposes here. It's not necessarily the ideal approach, just an example. The OP asked for a "workaround". This is one such example :) – ManoDestra Apr 11 '16 at 14:01
  • 1
    Yeah, that said, it looks like the `IntegerSpinnerValueFactory` handles "out of range" values using a "revert to valid" strategy, which I don't really like either. – James_D Apr 11 '16 at 14:09
  • 1
    I tried the code and it works but one thing is puzzling me. After adding the listener to the value property, the listener set on the same property from the `IntegerSpinnerValueFactory` constructor is never called any more. It is as if `addListener` actually is `replaceAllListeners`. Personally I believe a revert to valid strategy is a sane choice. Possibly "leave value and colour it red" would also work. – Emily L. Apr 11 '16 at 14:11
  • @EmilyL.Yes, that's precisely what I was thinking also. It's open as to how you implement your own requirements here. But, at least, we have something that kind of works to get round the issue of invalidation of the control's value. – ManoDestra Apr 11 '16 at 14:35
1

I would consider this a bug: the IntegerSpinnerValueFactory should properly handle this case.

One workaround is to provide a converter to the spinner value factory that evaluates to a default value if the text value is not valid:

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.Spinner;
import javafx.scene.control.SpinnerValueFactory.IntegerSpinnerValueFactory;
import javafx.stage.Stage;
import javafx.util.StringConverter;

public class Test extends Application {
    public static void main(String[] args) {
        launch(args);
    }

    @Override
    public void start(Stage aPrimaryStage) throws Exception {
        IntegerSpinnerValueFactory valueFactory = new IntegerSpinnerValueFactory(0, 10);

        valueFactory.setConverter(new StringConverter<Integer>() {

            @Override
            public String toString(Integer object) {
                return object.toString() ;
            }

            @Override
            public Integer fromString(String string) {
                if (string.matches("-?\\d+")) {
                    return new Integer(string);
                }
                // default to 0:
                return 0 ;
            }

        });

        Spinner<Integer> spinner = new Spinner<>(valueFactory);
        spinner.setEditable(true);
        aPrimaryStage.setScene(new Scene(spinner));
        aPrimaryStage.show();
    }
}
James_D
  • 201,275
  • 16
  • 291
  • 322
  • Although working, there are cases where a default value isn't applicable (or possibly not even in the domain of values for the spinner). – Emily L. Apr 11 '16 at 14:00
  • In that case you can provide an implementation of the value factory... Just seeing if I can make that work – James_D Apr 11 '16 at 14:02
  • In JavaFX 11 this fix works for the first time Spinner lost focus (and the text is empty), second time on wards does not seem to work (unless you change the value to some other valid value and then change it back to empty). – Venkata Raju Dec 19 '18 at 11:22
1

It's a known bug which is fixed in Java 9 - see https://bugs.openjdk.java.net/browse/JDK-8150962

wzberger
  • 923
  • 6
  • 15
  • I'm using JavaFX 11, still seeing this error. Bug was fixed for [`DoubleSpinnerValueFactory`](http://hg.openjdk.java.net/openjfx/9-dev/rt/rev/c1c50a773735) but not for `IntegerSpinnerValueFactory` – Venkata Raju Dec 19 '18 at 11:14
  • Another bug in `IntegerSpinnerValueFactory`, 'does not wrap around the value to minimum'. Question in [stackoverflow](https://stackoverflow.com/questions/53950933/javafx-integer-spinner-integerspinnervaluefactory-does-not-wrap-around-the-val/53951100#comment94740834_53951100). Bug reported in [Open JDK Bug System](https://bugs.openjdk.java.net/browse/JDK-8193286) – Venkata Raju Jan 07 '19 at 14:33
0

Another answer which worked for me, stopping you from entering any non-number value:

spinnerIndex.editorProperty().getValue().textProperty().addListener(new ChangeListener<String>() {

    private static boolean isInteger(final String s) {
        try {
            @SuppressWarnings("unused")
            int d = Integer.parseInt(s);
            return true;
        }
        catch (NumberFormatException e) {
            return false;
        }
    }

    @Override
    public void changed(ObservableValue<? extends String> observable, String oldValue, String newValue) {
        if (!isInteger(newValue)) {
            final StringProperty sp = (StringProperty)observable;
            sp.set(oldValue);
        }
    }
});
Andy Nugent
  • 859
  • 7
  • 21