Search code examples
javajavafxfxmljava-threads

Javafx where to bind labels to StringProperty


I've been struggling with this for several days, I've read about threads, MVC, bindings, interfaces and many interesting things but I just cant put them all together in the appropriate way to make this work.

I just want to list all the files in mi c:\ and display them on a changing label But all I get is:

Exception in thread "Thread-4" java.lang.IllegalStateException: Not on FX application thread; currentThread = Thread-4

This is my FXML:

<?import javafx.scene.control.Button?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.layout.AnchorPane?>
<?import javafx.scene.layout.ColumnConstraints?>
<?import javafx.scene.layout.GridPane?>
<?import javafx.scene.layout.RowConstraints?>


<GridPane alignment="center" hgap="10" prefHeight="200.0" prefWidth="401.0" vgap="10" xmlns:fx="http://javafx.com/fxml/1" xmlns="http://javafx.com/javafx/8.0.60" fx:controller="sample.Controller">
   <columnConstraints>
      <ColumnConstraints />
   </columnConstraints>
   <rowConstraints>
      <RowConstraints />
   </rowConstraints>
   <children>
      <AnchorPane prefHeight="200.0" prefWidth="368.0">
         <children>
            <Button fx:id="start" layoutX="159.0" layoutY="35.0" mnemonicParsing="false" onAction="#displayFiles" text="Start" />
            <Label fx:id="fileLabel" layoutX="20.0" layoutY="100.0" prefHeight="21.0" prefWidth="329.0" text="This label must change on iteration" />
         </children>
      </AnchorPane>
   </children>
</GridPane>

My Main:

import javafx.application.Application;
import javafx.fxml.FXML;
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 Exception{
        Parent root = FXMLLoader.load(getClass().getResource("sample.fxml"));
        primaryStage.setTitle("Dummy App");
        primaryStage.setScene(new Scene(root));
        primaryStage.setResizable(false);


        primaryStage.show();
    }


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

My Controller:

import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.scene.control.Button;
import javafx.scene.control.Label;

public class Controller {
    @FXML
    Button start;

    @FXML
    Label fileLabel;

    @FXML
    void displayFiles(ActionEvent event) throws Exception{
        Model model = new Model();

        //BINDING
        fileLabel.textProperty().bind(model.status);

        Thread thread = new Thread(model);

        thread.start();
    }

}

And the Model:

import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;

import java.io.File;

/**
 * Created by R00715649 on 16-Nov-16.
 */
public class Model implements Runnable {
    File rootDirectory = new File("C:/");
    StringProperty status = new SimpleStringProperty("Starting scan...");

    @Override
    public void run() {
        try{

            File[] fileList = rootDirectory.listFiles();
            for (File f:fileList){
                processDirectory(f);
            }

        }catch (Exception e){

        }
    }

    void processDirectory (File directory){
        if (directory.isDirectory()){
            File[] fileList = directory.listFiles();
            for (File f:fileList){
                processDirectory(f);
            }

        }else{
            System.out.println(directory.getAbsolutePath());
            status.set(directory.getAbsolutePath());
        }

    }
}

Solution

  • Since the text of the label is bound to the model's status, changing the model's status results in a change in the UI (the text of the label will change). Consequently, you can only change the model's status on the FX Application Thread.

    You can schedule code to run on the FX Application Thread using Platform.runLater(...). You can either do this in the model directly:

    void processDirectory (File directory){
        if (directory.isDirectory()){
            File[] fileList = directory.listFiles();
            for (File f:fileList){
                processDirectory(f);
            }
    
        }else{
            System.out.println(directory.getAbsolutePath());
            Platform.runLater(() -> status.set(directory.getAbsolutePath()));
        }
    
    }
    

    or you can register a listener with the model's status (instead of the binding), and delegate to the FX Application Thread there:

    @FXML
    void displayFiles(ActionEvent event) throws Exception{
        Model model = new Model();
    
        ChangeListener<String> listener = (obs, oldStatus, newStatus) -> fileLabel.setText(newStatus);
        model.status.addListener(listener);
    
        Thread thread = new Thread(model);
    
        thread.start();
    }
    

    In the latter solution, you will probably want to remove the listener when the thread finishes (which needs some additional work), as otherwise the model cannot be garbage collected while the label is still displayed. To this end, you could consider using a Task:

    @FXML
    void displayFiles(ActionEvent event) throws Exception{
        Model model = new Model();
    
        Task<Void> task = new Task<Void>() {
            @Override
            public Void call() {
                model.status.addListener((obs, oldStatus, newStatus) -> updateMessage(newStatus));
                model.run();
                return null ;
            }
        };
    
        fileLabel.textProperty().bind(task.messageProperty());
    
        task.setOnSucceeded(e -> fileLabel.textProperty().unbind());
    
        new Thread(task).start();
    }