I have a javafx application where I have multiple tabs (timer, stopwatch, clock) each with a separate Controller, and the user is able to add and independently start multiple timers using a start/stop button.
I tried binding a TextField to a property of another class MyTimer which keeps track of the elapsed time, but it eventually (after running for a couple of seconds) starts throwing an error. (If you check the code below, note that it only happens if the "Thread.sleep" is set to 10ms - when i increase the delay to 100ms, it kept running for about a minute and did not crash - I did not test further, since I would like to solve the root cause instead of increasing the delay).
Just so you have a quick idea: app image
MyTimer class:
public class MyTimer implements Startable {
...
private long startNanoTime, storedElapsedTime, totalTime;
private TimerStates state;
private StringProperty timerStringProperty = new SimpleStringProperty(DEFAULT_TIMER_STRING_VALUE);
public MyTimer() {
//constructor
}
public long getRemainingTime() {
//returns remaining time
}
public StringProperty timerStringPropertyProperty() {
return timerStringProperty;
}
@Override
public boolean start() {
if (this.state.isRunning() ) {
System.out.println("Already running.");
return false;
}
this.startNanoTime = System.nanoTime();
this.state = TimerStates.RUNNING;
Runnable startTimerRunnable = new Runnable() {
@Override
public void run() {
while(state.isRunning()) {
timerStringProperty.set(MyFormatter.longMillisecondsTimeToTimeString(getRemainingTime())); //The parameter passed is simply the remaining time formatted to a String
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
Thread daemonTimer = new Thread(startTimerRunnable);
daemonTimer.setDaemon(true);
daemonTimer.start();
return true;
}
}
While trying to implement the binding, I tried to bind the one default TextProperty which exists without any user interaction at application startup to the property representing the remaining time from the MyTimer class to the value in the Controller:
public class TimerTabController {
...
@FXML
private Tab timerTab;
@FXML
private HBox defaultTimerHBox;
@FXML
private TextField defaultTimerTextField;
private Map<HBox, MyTimer> timers = new HashMap<>();
@FXML
protected void initialize() {
MyTimer defaultTimer = new MyTimer();
timers.put(defaultTimerHBox, defaultTimer);
defaultTimerTextField.textProperty().bind(defaultTimer.timerStringPropertyProperty());
}
}
The Main method which starts it all up is fairly standard, but I'll include it anyway:
public class Main extends Application {
@Override
public void start(Stage primaryStage) throws Exception{
Parent root = FXMLLoader.load(getClass().getResource("fxml/mainWindow.fxml"));
primaryStage.setTitle("Mortimer");
primaryStage.setScene(new Scene(root, 800, 700));
primaryStage.show();
}
public static void main(String[] args) {
launch(args);
}
}
And finally, the stack trace: First, there's this:
Exception in thread "Thread-3" java.lang.NullPointerException
at javafx.graphics/com.sun.javafx.text.PrismTextLayout.createLine(PrismTextLayout.java:893)
at javafx.graphics/com.sun.javafx.text.PrismTextLayout.layout(PrismTextLayout.java:1193)
at javafx.graphics/com.sun.javafx.text.PrismTextLayout.ensureLayout(PrismTextLayout.java:222)
at javafx.graphics/com.sun.javafx.text.PrismTextLayout.getBounds(PrismTextLayout.java:245)
at javafx.graphics/javafx.scene.text.Text.getLogicalBounds(Text.java:430)
at javafx.graphics/javafx.scene.text.Text.getYRendering(Text.java:1085)
at javafx.graphics/javafx.scene.text.Text.access$4400(Text.java:127)
at javafx.graphics/javafx.scene.text.Text$TextAttribute$11.computeValue(Text.java:1764)
at javafx.graphics/javafx.scene.text.Text$TextAttribute$11.computeValue(Text.java:1756)
at javafx.base/javafx.beans.binding.ObjectBinding.get(ObjectBinding.java:151)
at javafx.base/javafx.beans.binding.ObjectExpression.getValue(ObjectExpression.java:49)
at javafx.base/javafx.beans.property.ObjectPropertyBase.get(ObjectPropertyBase.java:133)
at javafx.controls/javafx.scene.control.skin.TextFieldSkin.lambda$new$4(TextFieldSkin.java:252)
at javafx.base/com.sun.javafx.binding.ExpressionHelper$SingleInvalidation.fireValueChangedEvent(ExpressionHelper.java:136)
at javafx.base/com.sun.javafx.binding.ExpressionHelper.fireValueChangedEvent(ExpressionHelper.java:80)
at javafx.base/javafx.beans.property.ObjectPropertyBase.fireValueChangedEvent(ObjectPropertyBase.java:106)
at javafx.base/javafx.beans.property.ObjectPropertyBase.markInvalid(ObjectPropertyBase.java:113)
at javafx.base/javafx.beans.property.ObjectPropertyBase.access$000(ObjectPropertyBase.java:52)
at javafx.base/javafx.beans.property.ObjectPropertyBase$Listener.invalidated(ObjectPropertyBase.java:234)
at javafx.base/com.sun.javafx.binding.ExpressionHelper$SingleInvalidation.fireValueChangedEvent(ExpressionHelper.java:136)
at javafx.base/com.sun.javafx.binding.ExpressionHelper.fireValueChangedEvent(ExpressionHelper.java:80)
at javafx.base/javafx.beans.binding.ObjectBinding.invalidate(ObjectBinding.java:170)
at javafx.graphics/javafx.scene.text.Text.doGeomChanged(Text.java:842)
at javafx.graphics/javafx.scene.text.Text.access$500(Text.java:127)
at javafx.graphics/javafx.scene.text.Text$1.doGeomChanged(Text.java:158)
at javafx.graphics/com.sun.javafx.scene.shape.TextHelper.geomChangedImpl(TextHelper.java:106)
at javafx.graphics/com.sun.javafx.scene.NodeHelper.geomChanged(NodeHelper.java:137)
at javafx.graphics/javafx.scene.text.Text.needsTextLayout(Text.java:266)
at javafx.graphics/javafx.scene.text.Text.needsFullTextLayout(Text.java:261)
at javafx.graphics/javafx.scene.text.Text.access$900(Text.java:127)
at javafx.graphics/javafx.scene.text.Text$3.invalidated(Text.java:461)
at javafx.base/javafx.beans.property.StringPropertyBase.markInvalid(StringPropertyBase.java:110)
at javafx.base/javafx.beans.property.StringPropertyBase.access$000(StringPropertyBase.java:50)
at javafx.base/javafx.beans.property.StringPropertyBase$Listener.invalidated(StringPropertyBase.java:231)
at javafx.base/com.sun.javafx.binding.ExpressionHelper$SingleInvalidation.fireValueChangedEvent(ExpressionHelper.java:136)
at javafx.base/com.sun.javafx.binding.ExpressionHelper.fireValueChangedEvent(ExpressionHelper.java:80)
at javafx.base/javafx.beans.binding.StringBinding.invalidate(StringBinding.java:169)
at javafx.base/com.sun.javafx.binding.BindingHelperObserver.invalidated(BindingHelperObserver.java:52)
at javafx.base/com.sun.javafx.binding.ExpressionHelper$Generic.fireValueChangedEvent(ExpressionHelper.java:348)
at javafx.base/com.sun.javafx.binding.ExpressionHelper.fireValueChangedEvent(ExpressionHelper.java:80)
at javafx.controls/javafx.scene.control.TextInputControl$TextProperty.fireValueChangedEvent(TextInputControl.java:1430)
at javafx.controls/javafx.scene.control.TextInputControl$TextProperty.markInvalid(TextInputControl.java:1434)
at javafx.controls/javafx.scene.control.TextInputControl$TextProperty.controlContentHasChanged(TextInputControl.java:1373)
at javafx.controls/javafx.scene.control.TextInputControl$TextProperty.access$1600(TextInputControl.java:1341)
at javafx.controls/javafx.scene.control.TextInputControl.lambda$new$0(TextInputControl.java:144)
at javafx.base/com.sun.javafx.binding.ExpressionHelper$SingleInvalidation.fireValueChangedEvent(ExpressionHelper.java:136)
at javafx.base/com.sun.javafx.binding.ExpressionHelper.fireValueChangedEvent(ExpressionHelper.java:80)
at javafx.controls/javafx.scene.control.TextField$TextFieldContent.insert(TextField.java:87)
at javafx.controls/javafx.scene.control.TextInputControl.replaceText(TextInputControl.java:1244)
at javafx.controls/javafx.scene.control.TextInputControl.filterAndSet(TextInputControl.java:1211)
at javafx.controls/javafx.scene.control.TextInputControl.access$900(TextInputControl.java:80)
at javafx.controls/javafx.scene.control.TextInputControl$TextProperty.doSet(TextInputControl.java:1451)
at javafx.controls/javafx.scene.control.TextInputControl$TextProperty.access$1200(TextInputControl.java:1341)
at javafx.controls/javafx.scene.control.TextInputControl$TextProperty$Listener.invalidated(TextInputControl.java:1474)
at javafx.base/com.sun.javafx.binding.ExpressionHelper$SingleInvalidation.fireValueChangedEvent(ExpressionHelper.java:136)
at javafx.base/com.sun.javafx.binding.ExpressionHelper.fireValueChangedEvent(ExpressionHelper.java:80)
at javafx.base/javafx.beans.property.StringPropertyBase.fireValueChangedEvent(StringPropertyBase.java:104)
at javafx.base/javafx.beans.property.StringPropertyBase.markInvalid(StringPropertyBase.java:111)
at javafx.base/javafx.beans.property.StringPropertyBase.set(StringPropertyBase.java:145)
at javafx.base/javafx.beans.property.StringPropertyBase.set(StringPropertyBase.java:50)
at MorTimer/sample.MyTimer$1.run(MyTimer.java:106)
at java.base/java.lang.Thread.run(Thread.java:834)
And then the following errors keep repeating:
Exception in thread "JavaFX Application Thread" java.lang.NullPointerException
at javafx.graphics/com.sun.javafx.text.PrismTextLayout.getRuns(PrismTextLayout.java:235)
at javafx.graphics/javafx.scene.text.Text.getRuns(Text.java:389)
at javafx.graphics/javafx.scene.text.Text.updatePGText(Text.java:1460)
at javafx.graphics/javafx.scene.text.Text.doUpdatePeer(Text.java:1490)
at javafx.graphics/javafx.scene.text.Text.access$100(Text.java:127)
at javafx.graphics/javafx.scene.text.Text$1.doUpdatePeer(Text.java:137)
at javafx.graphics/com.sun.javafx.scene.shape.TextHelper.updatePeerImpl(TextHelper.java:75)
at javafx.graphics/com.sun.javafx.scene.NodeHelper.updatePeer(NodeHelper.java:102)
at javafx.graphics/javafx.scene.Node.syncPeer(Node.java:710)
at javafx.graphics/javafx.scene.Scene$ScenePulseListener.synchronizeSceneNodes(Scene.java:2366)
at javafx.graphics/javafx.scene.Scene$ScenePulseListener.pulse(Scene.java:2512)
at javafx.graphics/com.sun.javafx.tk.Toolkit.lambda$runPulse$2(Toolkit.java:412)
at java.base/java.security.AccessController.doPrivileged(Native Method)
at javafx.graphics/com.sun.javafx.tk.Toolkit.runPulse(Toolkit.java:411)
at javafx.graphics/com.sun.javafx.tk.Toolkit.firePulse(Toolkit.java:438)
at javafx.graphics/com.sun.javafx.tk.quantum.QuantumToolkit.pulse(QuantumToolkit.java:519)
at javafx.graphics/com.sun.javafx.tk.quantum.QuantumToolkit.pulse(QuantumToolkit.java:499)
at javafx.graphics/com.sun.javafx.tk.quantum.QuantumToolkit.pulseFromQueue(QuantumToolkit.java:492)
at javafx.graphics/com.sun.javafx.tk.quantum.QuantumToolkit.lambda$runToolkit$11(QuantumToolkit.java:320)
at javafx.graphics/com.sun.glass.ui.InvokeLaterDispatcher$Future.run(InvokeLaterDispatcher.java:96)
at javafx.graphics/com.sun.glass.ui.win.WinApplication._runLoop(Native Method)
at javafx.graphics/com.sun.glass.ui.win.WinApplication.lambda$runLoop$3(WinApplication.java:174)
at java.base/java.lang.Thread.run(Thread.java:834)
Exception in thread "JavaFX Application Thread" java.lang.NullPointerException
at javafx.graphics/javafx.scene.Scene$ScenePulseListener.synchronizeSceneNodes(Scene.java:2365)
at javafx.graphics/javafx.scene.Scene$ScenePulseListener.pulse(Scene.java:2512)
at javafx.graphics/com.sun.javafx.tk.Toolkit.lambda$runPulse$2(Toolkit.java:412)
at java.base/java.security.AccessController.doPrivileged(Native Method)
at javafx.graphics/com.sun.javafx.tk.Toolkit.runPulse(Toolkit.java:411)
at javafx.graphics/com.sun.javafx.tk.Toolkit.firePulse(Toolkit.java:438)
at javafx.graphics/com.sun.javafx.tk.quantum.QuantumToolkit.pulse(QuantumToolkit.java:519)
at javafx.graphics/com.sun.javafx.tk.quantum.QuantumToolkit.pulse(QuantumToolkit.java:499)
at javafx.graphics/com.sun.javafx.tk.quantum.QuantumToolkit.pulseFromQueue(QuantumToolkit.java:492)
at javafx.graphics/com.sun.javafx.tk.quantum.QuantumToolkit.lambda$runToolkit$11(QuantumToolkit.java:320)
at javafx.graphics/com.sun.glass.ui.InvokeLaterDispatcher$Future.run(InvokeLaterDispatcher.java:96)
at javafx.graphics/com.sun.glass.ui.win.WinApplication._runLoop(Native Method)
at javafx.graphics/com.sun.glass.ui.win.WinApplication.lambda$runLoop$3(WinApplication.java:174)
at java.base/java.lang.Thread.run(Thread.java:834)
When I used Platform.runLater() in the MyTimer.start() function, it did work, like this:
Runnable startTimerRunnable = new Runnable() {
@Override
public void run() {
while(state.isRunning()) {
Platform.runLater(new Runnable() {
@Override
public void run() {
timerStringProperty.set(MyFormatter.longMillisecondsTimeToTimeString(getRemainingTime()));
}
});
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
Thread daemonTimer = new Thread(startTimerRunnable);
daemonTimer.setDaemon(true);
daemonTimer.start();
But it seems wrong to use Platform.runLater like this, or is it fine? I understand that the GUI should not be updated outside of the FX thread, but I thought that binding properties takes care of this - that updating the property bound to a GUI element does not need to take place in the FX thread, and the GUI update would indeed be handled properly on the FX thread as part of the binding...
Ultimately, my solution which seems to work fairly well is to not use binding, but instead update the fields periodically in the Controller itself (the following method is called upon "onSelectionChanged" of the Tabs themselves) - but I was wondering how to make the binding work, as it seems that binding is the better practice. Anyway, here is the code which also does work:
public void startTimeFieldUpdates() {
Runnable timeTracker = new Runnable() {
@Override
public void run() {
while(timerTab.isSelected()) {
Platform.runLater(new Runnable() {
@Override
public void run() {
for (HBox hBox : timers.keySet()) {
if (hBox.getChildren().get(1) instanceof TextField) {
TextField currentField = (TextField) hBox.getChildren().get(1);
currentField.setText(MyFormatter.longMillisecondsTimeToTimeString(
timers.get(hBox).getRemainingTime())
);
}
}
}
});
try {
Thread.sleep(10);
} catch (InterruptedException ex) {
ex.printStackTrace();
}
}
}
};
Thread daemonStopwatch = new Thread(timeTracker);
daemonStopwatch.setDaemon(true);
daemonStopwatch.start();
}
My question then is, what is the correct approach to this problem, please?