I\'m creating a 20-minute countdown timer application. I\'m using JavaFX SceneBuilder to do this. The timer is composed of two labels (one for minutes, one for seconds--each com
As I mentioned in a comment, using a background thread for this, let alone three(!) background threads, will only make this harder to implement and reason about. It would be better to use the animation API provided by JavaFX—it's asynchronous but still executes on the JavaFX Application Thread. And as mentioned by others, you only need one value to represent the time remaining and another value representing the duration. From there you can display the minutes, seconds, and progress.
Personally, I would use an AnimationTimer as it gives you the timestamp of the current frame which you can use to calculate how much time is left. To make things easier to use I would also wrap the AnimationTimer
in another class and have that latter class expose an API more appropriate for countdown timers. For example:
package com.example;
import java.util.concurrent.TimeUnit;
import javafx.animation.AnimationTimer;
import javafx.beans.property.LongProperty;
import javafx.beans.property.ReadOnlyDoubleProperty;
import javafx.beans.property.ReadOnlyDoubleWrapper;
import javafx.beans.property.ReadOnlyLongProperty;
import javafx.beans.property.ReadOnlyLongWrapper;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.SimpleLongProperty;
public class CountdownTimer {
private static long toMillis(long nanos) {
return TimeUnit.NANOSECONDS.toMillis(nanos);
}
/* *********************************************************************
* *
* Instance Fields *
* *
***********************************************************************/
private final Timer timer = new Timer();
private long cachedDuration;
/* *********************************************************************
* *
* Constructors *
* *
***********************************************************************/
public CountdownTimer() {}
public CountdownTimer(long duration) {
setDuration(duration);
}
/* *********************************************************************
* *
* Public API *
* *
***********************************************************************/
public void start() {
if (getStatus() == Status.READY || getStatus() == Status.PAUSED) {
timer.start();
setStatus(Status.RUNNING);
}
}
public void pause() {
if (getStatus() == Status.RUNNING) {
timer.pause();
setStatus(Status.PAUSED);
}
}
public void stopAndReset() {
timer.stopAndReset();
setStatus(Status.READY);
}
/* *********************************************************************
* *
* Properties *
* *
***********************************************************************/
private final ReadOnlyObjectWrapper<Status> status = new ReadOnlyObjectWrapper<>(this, "status", Status.READY) {
@Override protected void invalidated() {
if (get() == Status.READY) {
cachedDuration = Math.abs(getDuration());
setTimeRemaining(cachedDuration);
}
}
};
private void setStatus(Status status) { this.status.set(status); }
public final Status getStatus() { return status.get(); }
public final ReadOnlyObjectProperty<Status> statusProperty() { return status.getReadOnlyProperty(); }
private final LongProperty duration = new SimpleLongProperty(this, "duration") {
@Override protected void invalidated() {
if (getStatus() == Status.READY) {
cachedDuration = Math.abs(get());
setTimeRemaining(cachedDuration);
}
}
};
public final void setDuration(long duration) { this.duration.set(duration); }
public final long getDuration() { return duration.get(); }
public final LongProperty durationProperty() { return duration; }
private final ReadOnlyLongWrapper timeRemaining = new ReadOnlyLongWrapper(this, "timeRemaining") {
@Override protected void invalidated() {
setProgress((double) (cachedDuration - get()) / (double) cachedDuration);
}
};
private void setTimeRemaining(long timeRemaining) { this.timeRemaining.set(timeRemaining); }
public final long getTimeRemaining() { return timeRemaining.get(); }
public final ReadOnlyLongProperty timeRemainingProperty() { return timeRemaining.getReadOnlyProperty(); }
private final ReadOnlyDoubleWrapper progress = new ReadOnlyDoubleWrapper(this, "progress");
private void setProgress(double progress) { this.progress.set(progress); }
public final double getProgress() { return progress.get(); }
public final ReadOnlyDoubleProperty progressProperty() { return progress.getReadOnlyProperty(); }
/* *********************************************************************
* *
* Static Classes *
* *
***********************************************************************/
public enum Status {
READY,
RUNNING,
PAUSED,
FINISHED
}
/* *********************************************************************
* *
* Classes *
* *
***********************************************************************/
private class Timer extends AnimationTimer {
private long triggerTime = Long.MIN_VALUE;
private long pauseTime = Long.MIN_VALUE;
private boolean pausing;
@Override
public void handle(long now) {
if (pausing) {
pauseTime = toMillis(now);
pausing = false;
stop();
} else {
if (triggerTime == Long.MIN_VALUE) {
triggerTime = toMillis(now) + cachedDuration;
} else if (pauseTime != Long.MIN_VALUE) {
triggerTime += toMillis(now) - pauseTime;
pauseTime = Long.MIN_VALUE;
}
long timeRemaining = Math.max(0, triggerTime - toMillis(now));
setTimeRemaining(timeRemaining);
if (timeRemaining == 0) {
setStatus(Status.FINISHED);
stop();
}
}
}
@Override
public void start() {
pausing = false;
super.start();
}
void pause() {
if (triggerTime != Long.MIN_VALUE) {
pausing = true;
} else {
stop();
}
}
void stopAndReset() {
stop();
triggerTime = Long.MIN_VALUE;
pauseTime = Long.MIN_VALUE;
pausing = false;
}
}
}
Warning: While the AnimationTimer
is running the CountdownTimer
instance cannot be garbage collected.
This implementation interprets both the duration and time remaining values as milliseconds. Also, changing the duration after starting the timer has no effect until after the timer is reset (i.e. calling stopAndReset()
).
Here's an example of using the above CountdownTimer
in an FXML-based application. Note that the example uses distinct buttons for starting, pausing, resuming, and resetting the timer. This is different than what you described in your question but you should be able to rework things to fit your needs. Also, the example provides a way to toggle whether or not the millisecond of the current second is shown.
App.fxml:
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.ProgressBar?>
<?import javafx.scene.control.ToolBar?>
<?import javafx.scene.layout.StackPane?>
<?import javafx.scene.layout.VBox?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.Separator?>
<?import javafx.scene.control.CheckBox?>
<?import com.example.CountdownTimer?>
<?import com.example.CountdownTimer.Status?>
<VBox xmlns="http://javafx.com/javafx/14.0.1" xmlns:fx="http://javafx.com/fxml/1"
fx:controller="com.example.Controller" prefHeight="300" prefWidth="500">
<fx:define>
<!-- 90,000ms == 1m 30s -->
<CountdownTimer fx:id="timer" duration="90000"/>
<CountdownTimer.Status fx:id="READY" fx:value="READY"/>
<CountdownTimer.Status fx:id="RUNNING" fx:value="RUNNING"/>
<CountdownTimer.Status fx:id="PAUSED" fx:value="PAUSED"/>
</fx:define>
<ToolBar style="-fx-font: 10pt 'Monospaced';">
<Button text="Start" disable="${timer.status != READY}" focusTraversable="false"
onAction="#handleStartOrResumeTimer"/>
<Button text="Resume" disable="${timer.status != PAUSED}" focusTraversable="false"
onAction="#handleStartOrResumeTimer"/>
<Button text="Pause" disable="${timer.status != RUNNING}" focusTraversable="false" onAction="#handlePauseTimer"/>
<Button text="Reset" disable="${timer.status == READY || timer.status == RUNNING}" focusTraversable="false"
onAction="#handleResetTimer"/>
<Separator/>
<CheckBox fx:id="showMillisBox" text="Show Millis" focusTraversable="false"/>
</ToolBar>
<ProgressBar progress="${timer.progress}" maxWidth="Infinity"/>
<StackPane VBox.vgrow="ALWAYS">
<Label fx:id="timerLabel" style="-fx-font: bold 48pt 'Monospaced';"/>
</StackPane>
</VBox>
Controller.java:
package com.example;
import java.time.Duration;
import javafx.beans.binding.Bindings;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.scene.control.CheckBox;
import javafx.scene.control.Label;
import javafx.scene.paint.Color;
public class Controller {
@FXML private CountdownTimer timer;
@FXML private CheckBox showMillisBox;
@FXML private Label timerLabel;
@FXML
private void initialize() {
timerLabel
.textProperty()
.bind(
Bindings.createStringBinding(
this::formatTimeRemaining,
timer.timeRemainingProperty(),
showMillisBox.selectedProperty()));
timerLabel
.textFillProperty()
.bind(
Bindings.when(timer.statusProperty().isEqualTo(CountdownTimer.Status.FINISHED))
.then(Color.FIREBRICK)
.otherwise(Color.FORESTGREEN));
}
private String formatTimeRemaining() {
Duration d = Duration.ofMillis(timer.getTimeRemaining());
if (showMillisBox.isSelected()) {
return String.format("%02d:%02d:%03d", d.toMinutes(), d.toSecondsPart(), d.toMillisPart());
}
return String.format("%02d:%02d", d.toMinutes(), d.toSecondsPart());
}
@FXML
private void handleStartOrResumeTimer(ActionEvent event) {
event.consume();
timer.start();
}
@FXML
private void handlePauseTimer(ActionEvent event) {
event.consume();
timer.pause();
}
@FXML
private void handleResetTimer(ActionEvent event) {
event.consume();
timer.stopAndReset();
}
}
Main.java:
package com.example;
import java.io.IOException;
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Stage;
public class Main extends Application {
@Override
public void start(Stage primaryStage) throws IOException {
Parent root = FXMLLoader.load(getClass().getResource("/com/example/App.fxml"));
primaryStage.setScene(new Scene(root));
primaryStage.setTitle("Countdown Timer Example");
primaryStage.show();
}
}