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?
The answer, as posted in the comments to my question, is indeed that property binding uses listeners which ultimately run on the same thread where the property itself is updated - so, to avoid problems, the bound properties need to be updated on the Java FX Application Thread (or another solution should be sought, as was my case).
The solution that worked for me - as mentioned in the comments to my question, I looked at what AnimationTimer
is, and it seems to be exactly what I was looking for and works perfectly.
In case it helps someone, here is my implementation:
import ...
public class TimerTabController {
public static final int TIMER_HBOX_TEXTFIELD_INDEX = 1;
public static final int TIMER_HBOX_STARTSTOP_BUTTON_INDEX = 2;
@FXML
private Tab timerTab;
@FXML
private HBox defaultTimerHBox;
@FXML
private TextField defaultTimerTextField;
private Map<HBox, MyTimer> timers = new HashMap<>();
private AnimationTimer timerTabAnimationTimer = new AnimationTimer() {
@Override
public void handle(long l) {
//the GUI updates go here
for (HBox hBox : timers.keySet()) {
if (hBox.getChildren().get(TIMER_HBOX_TEXTFIELD_INDEX) instanceof TextField) {
TextField currentField = (TextField) hBox.getChildren().get(TIMER_HBOX_TEXTFIELD_INDEX);
currentField.setText(MyFormatter.longMillisecondsTimeToTimeString(
timers.get(hBox).getRemainingTime())
);
}
}
}
};
@FXML
protected void initialize() {
timers.put(defaultTimerHBox, new MyTimer());
}
@FXML
void handleSelectionChanged() { //triggered by changing tab selection
if (timerTab.isSelected()) {
timerTabAnimationTimer.start();
} else {
timerTabAnimationTimer.stop();
}
}
//some more code
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With