Search code examples
javajavafxtextflow

Why is the Text in my TextFlow rendering only partially?


I am creating an object called Event using a Dialog box. Event has the properties title and description. Upon creating the Event object and exiting from the Dialog, I am displaying the title and description of the new Event in a TextFlow. The issue is, the title text is only partially rendering:

Example of Partial Rendering Problem

Example 2
Example 3

The following is the relevant code:

Main.java

package com.example.eventplanner;

import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Stage;

import java.io.IOException;
import java.util.Objects;

public class Main extends Application {
    @Override
    public void start(Stage stage) throws IOException {
        Parent root = FXMLLoader.load(Objects.requireNonNull(getClass().getResource("Main.fxml")));

        Scene main = new Scene(root);

        stage.setScene(main);

        stage.show();


    }

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

MainController.java

package com.example.eventplanner;

import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.scene.control.ListView;
import javafx.scene.input.MouseEvent;
import javafx.scene.text.TextFlow;
import java.io.IOException;
import java.util.Optional;

public class MainController {

    private final EventBoard eventBoard = EventBoard.getInstance();

    @FXML
    private ListView<Event> eventListView;

    @FXML
    private TextFlow eventInfoTextFlow;

    @FXML
    private void onCreateEvent(ActionEvent event) throws IOException {
        // made this an optional in the case that the user closes the dialog without creating an event
        Optional<Event> optionalCreatedEvent =  Optional.ofNullable(eventBoard.createEvent());
        if(optionalCreatedEvent.isPresent()) {
            eventBoard.setSelectedEvent(optionalCreatedEvent.get());
            eventBoard.loadEvents(eventListView);
            eventBoard.displaySelectedEventInfo(eventInfoTextFlow);
        }
    }

    @FXML
    private void onSelectEvent(MouseEvent event) throws IOException, InterruptedException {
        // set selected event in event board
        eventBoard.setSelectedEvent(eventListView.getSelectionModel().getSelectedItem());
        // load info from the selected event into the info text flow
        eventBoard.displaySelectedEventInfo(eventInfoTextFlow);
    }

}

EventBoard.java

package com.example.eventplanner;

import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.control.Dialog;
import javafx.scene.control.ListView;
import javafx.scene.text.Font;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;

import java.util.Optional;

public class EventBoard {

    private static final EventBoard INSTANCE = new EventBoard();

    public static EventBoard getInstance() {
        return INSTANCE;
    }

    private final ObservableList<Event> events;

    private Event selectedEvent;

    public EventBoard() {
        this.events = FXCollections.observableArrayList();
    }

    public Event createEvent() {
        Dialog<Event> eventDialog = new EventDialog(new Event());

        Optional<Event> optionalEvent = eventDialog.showAndWait();
        if(optionalEvent.isPresent()) {
            Event event = optionalEvent.get();
            System.out.println("Added event: " + event);
            events.add(event);
            return event;
        }
        return null;
    }

    public void loadEvents(ListView<Event> eventListView) {
        // clear list view for new load
        eventListView.getItems().clear();
        // add items to list view
        eventListView.getItems().addAll(this.events);
        // select the selected event
        eventListView.getSelectionModel().select(selectedEvent);
    }

    public Event getSelectedEvent() {
        return selectedEvent;
    }

    public void setSelectedEvent(Event selectedEvent) {
        this.selectedEvent = selectedEvent;
        System.out.println("The selected event is: " + selectedEvent);
        System.out.println("Its description is: " + selectedEvent.getDescription());
        System.out.println("And is on date: " + selectedEvent.getDate());
    }

    public void displaySelectedEventInfo(TextFlow eventInfoTextFlow) {

        Platform.runLater(()->{

            Event event = this.selectedEvent;

            if(!(eventInfoTextFlow.getChildren().isEmpty())) {
                // clear all text
                eventInfoTextFlow.getChildren().clear();
            }

            Text eventTitle = new Text(event.getTitle()+"\n"); // try removing the \n
            System.out.println("title: " + event.getTitle());
            eventTitle.setFont(Font.font("System", 25));

            Text eventDescription = new Text(event.getDescription());
            eventDescription.setFont(Font.font("System", 20));

            eventInfoTextFlow.getChildren().addAll(eventTitle, eventDescription);
            eventInfoTextFlow.setLineSpacing(5.0);

        });

    }

}

EventDialog.java

package com.example.eventplanner;

import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.scene.control.*;
import javafx.scene.layout.GridPane;
import javafx.util.Callback;


public class EventDialog extends Dialog<Event> {

    private final Event event;

    TextField titleTextField;
    TextField descriptionTextField;
    DatePicker eventDatePicker;
    ComboBox<String> hourComboBox;
    ComboBox<String> minuteComboBox;

    public EventDialog(Event event) {
        super();
        this.event = event;
        buildUI();              // build ui
        setPropertyBindings();  // set binding in text field
        setResultConverter();   // return diary object from dialog
    }

    private void buildUI() {

        Label newEventLabel = new Label("New Event");

        Label titleLabel = new Label("Title:");
        titleTextField = new TextField();

        Label descriptionLabel = new Label("Description:");
        descriptionTextField = new TextField();

        Label eventDateLabel = new Label("Date:");
        eventDatePicker = new DatePicker();
        eventDatePicker.getEditor().setEditable(false);
        eventDatePicker.getEditor().setMouseTransparent(true);
        eventDatePicker.getEditor().setFocusTraversable(false);

        Label eventTimeLabel = new Label("Time:");
        Label colonLabel = new Label("  :  ");
        GridPane eventTimeGridPane = new GridPane();
        hourComboBox = new ComboBox<>(ComboBoxUtil.getHourOptions());
        minuteComboBox = new ComboBox<>(ComboBoxUtil.getMinuteOptions());
        eventTimeGridPane.add(hourComboBox, 1, 0);
        eventTimeGridPane.add(colonLabel, 2, 0);
        eventTimeGridPane.add(minuteComboBox, 3, 0);


        GridPane gridpane = new GridPane();
        
        gridpane.add(newEventLabel, 1, 0);

        gridpane.add(titleLabel, 1, 1);
        gridpane.add(titleTextField, 2, 1);

        gridpane.add(descriptionLabel, 1, 2);
        gridpane.add(descriptionTextField, 2, 2);

        gridpane.add(eventDateLabel, 1, 3);
        gridpane.add(eventDatePicker, 2, 3);

        gridpane.add(eventTimeLabel, 1, 4);
        gridpane.add(eventTimeGridPane, 2, 4);

        gridpane.setHgap(10);
        gridpane.setVgap(10);

        // add gridpane to dialog pane
        getDialogPane().setContent(gridpane);



        // add cancel and ok buttons to dialog pane
        getDialogPane().getButtonTypes().addAll(ButtonType.CANCEL, ButtonType.OK);

        // logic to prevent ok button from closing if text field is empty
        Button button = (Button) getDialogPane().lookupButton(ButtonType.OK);
        button.addEventFilter(ActionEvent.ACTION, new EventHandler<ActionEvent>() {
            @Override
            public void handle(ActionEvent event) {
                if (!validateDialog()) {
                    // the consume method will prevent the dialog from closing if there is no data in the text field
                    event.consume();
                }
            }

            private boolean validateDialog() {
                // validates if there is data in the text field
                
                return !titleTextField.getText().isEmpty() && !descriptionTextField.getText().isEmpty()
                        && !eventDatePicker.getEditor().getText().isEmpty() && !hourComboBox.getSelectionModel().isEmpty()
                            && !minuteComboBox.getSelectionModel().isEmpty();
            }
        });
    }

    private void setPropertyBindings() {
        titleTextField.textProperty().bindBidirectional(event.titleProperty());
        descriptionTextField.textProperty().bindBidirectional(event.descriptionProperty());
        eventDatePicker.getEditor().textProperty().bindBidirectional(event.dateProperty());
        hourComboBox.valueProperty().bindBidirectional(event.hourProperty());
        minuteComboBox.valueProperty().bindBidirectional(event.minuteProperty());
    }
    
    private void setResultConverter() {

        Callback<ButtonType, Event> personResultConverter = new Callback<ButtonType, Event>() {
            @Override
            public Event call(ButtonType param) {
                if (param == ButtonType.OK) {
                    return event;
                } else {
                    return null;
                }
            }
        };

        setResultConverter(personResultConverter);
    }

}

ComboBoxUtil.java

package com.example.eventplanner;

import javafx.collections.FXCollections;
import javafx.collections.ObservableList;

import java.time.Instant;

public final class ComboBoxUtil {

    public static ObservableList<String> getHourOptions() {
        ObservableList<String> hourOptions = FXCollections.observableArrayList();
        for(int i = 0; i < 24; i++) {
            hourOptions.add(Integer.toString(i));
        }
        return hourOptions;
    }

    public static ObservableList<String> getMinuteOptions() {
        ObservableList<String> minuteOptions = FXCollections.observableArrayList();
        for(int i = 0; i < 60; i++) {
            if(i < 10) {
                minuteOptions.add("0"+Integer.toString(i));
            } else {
                minuteOptions.add(Integer.toString(i));
            }

        }
        return minuteOptions;
    }
}

Event.java

package com.example.eventplanner;

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

import java.util.Objects;

public class Event {

    private final StringProperty title;
    private final StringProperty description;
    private final StringProperty date;
    private final StringProperty hour;
    private final StringProperty minute;


    public Event() {
        this.title = new SimpleStringProperty("");
        this.description = new SimpleStringProperty("");
        this.date = new SimpleStringProperty("");
        this.hour = new SimpleStringProperty("");
        this.minute = new SimpleStringProperty("");
    }

    public String getTitle() {
        return title.get();
    }

    public StringProperty titleProperty() {
        return title;
    }

    public void setTitle(String title) {
        this.title.set(title);
    }

    public String getDescription() {
        return description.get();
    }

    public StringProperty descriptionProperty() {
        return description;
    }

    public void setDescription(String description) {
        this.description.set(description);
    }

    public String getDate() {
        return date.get();
    }

    public StringProperty dateProperty() {
        return date;
    }

    public void setDate(String date) {
        this.date.set(date);
    }

    public String getHour() {
        return hour.get();
    }

    public StringProperty hourProperty() {
        return hour;
    }

    public void setHour(String hour) {
        this.hour.set(hour);
    }

    public String getMinute() {
        return minute.get();
    }

    public StringProperty minuteProperty() {
        return minute;
    }

    public void setMinute(String minute) {
        this.minute.set(minute);
    }

    @Override
    public String toString() {
        return this.getTitle();
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Event event = (Event) o;
        return Objects.equals(title, event.title) && Objects.equals(description, event.description) && Objects.equals(date, event.date) && Objects.equals(hour, event.hour) && Objects.equals(minute, event.minute);
    }

    @Override
    public int hashCode() {
        return Objects.hash(title, description, date, hour, minute);
    }
}

Main.fxml

<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.ListView?>
<?import javafx.scene.layout.HBox?>
<?import javafx.scene.layout.VBox?>
<?import javafx.scene.text.TextFlow?>

<HBox maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" prefHeight="400.0" prefWidth="663.0" xmlns="http://javafx.com/javafx/20.0.1" xmlns:fx="http://javafx.com/fxml/1" fx:controller="com.example.eventplanner.MainController">
   <children>
      <VBox prefHeight="400.0" prefWidth="300.0" HBox.hgrow="ALWAYS">
         <children>
            <ListView fx:id="eventListView" onMouseClicked="#onSelectEvent" prefHeight="400.0" prefWidth="300.0" VBox.vgrow="ALWAYS" />
         </children>
      </VBox>
      <VBox prefHeight="400.0" prefWidth="300.0" HBox.hgrow="ALWAYS">
         <children>
            <TextFlow fx:id="eventInfoTextFlow" prefHeight="350.0" prefWidth="200.0" VBox.vgrow="ALWAYS">
               <padding>
                  <Insets bottom="10.0" left="10.0" right="10.0" top="10.0" />
               </padding></TextFlow>
            <HBox alignment="CENTER_LEFT" prefHeight="50.0" prefWidth="200.0" VBox.vgrow="NEVER">
               <padding>
                  <Insets bottom="5.0" left="5.0" right="5.0" top="5.0" />
               </padding>
               <children>
                  <Button fx:id="createEventButton" mnemonicParsing="false" onAction="#onCreateEvent" prefHeight="36.0" prefWidth="109.0" text="Create Event" />
               </children>
            </HBox>
         </children>
      </VBox>
   </children>
</HBox>

I have added Platform.runlater to see if running the UI update code in the Application Thread will fix this problem, but it has not (although it has fixed a related render issue when selecting one event and switching to another).

The following prevents the rendering issue

  1. Adding one or more spaces after the typed in text in the TextField in my Dialog
  2. If the title length is longer than a (seemingly random) length

The following fixes the partial render issue after it occurs:

  1. Clicking/selecting the Event in the ListView (after creation) fixes the render issue.
  2. Creating another new event. The render issue only happens on the first event created.
  3. Resizing the window of the App

The work around I am currently using is adding two spaces here:

Changed from this:

Text eventTitle = new Text(event.getTitle()+"\n");

To this:

Text eventTitle = new Text(event.getTitle()+"  \n");

This seems to fix it but I don't know why. I know forcing a refresh would probably also fix it (like resizing the window by 0.0001), but I like that solution even less. I think I am either not refreshing/updating my UI properly or incorrectly using TextFlow and Text somehow.

Edit: Added all relevant code to make the issue reproducible


Solution

  • Reproduction

    The clipping shown in the question replicated for me with the FXML provided in the question on JavaFX 21 on a Mac. Here is a broken (clipped output) for a value for the title field of "Title" and the description field of "E":

    clipped

    I didn't run a detailed analysis of why the clipping occurred. But strangely I couldn't make it occur all of the time. It would only occur sometimes.

    Minor modifications that appeared to fix the rendering issue

    I made minor modifications to the code and the FXML, which appeared to fix the issue. But perhaps I just got lucky running the updated code and having it work rather than fixing the issue.

    title and description

    I removed the unnecessary runLater call in displaySelectedEventInfo (likely did not affect rendering).

    I modified the FXML to remove some unnecessary hardcoded sizes (this is probably what fixed the issue).

    Additional modifications to fix bugs unrelated to TextFlow rendering

    In your question you noted:

    I have added Platform.runlater to see if running the UI update code in the Application Thread will fix this problem.

    However, from the application programmer's point of view, JavaFX is a single-threaded framework. All code will already be run on the JavaFX application thread once the framework is started (unless you explicitly start creating your own threads, which you don't do or need in this example). This is why in my example I don't use runLater.

    There were some other bugs in the logic of your code, as mentioned in comments:

    Try adding two different events with different titles and descriptions. When you switch between them, the description remains unchanged.

    The sticky (incorrect) description displayed is almost certainly due to a bug in your code (not a JavaFX library rendering issue).

    I rewrote parts of your code to allow it to work. This work was to remove the EventBoard class from your code and rely on the ListView for maintaining the event list and selected event instead of using your own class (which had bugs in it) to try to do this.

    Sample code

    Main, ComboBoxUtil, Event, and EventDialog code from your question is unchanged and not included here.

    MainController.java

    import javafx.fxml.FXML;
    import javafx.scene.control.Dialog;
    import javafx.scene.control.ListView;
    import javafx.scene.text.Font;
    import javafx.scene.text.Text;
    import javafx.scene.text.TextFlow;
    
    import java.util.Optional;
    
    public class MainController {
    
        @FXML
        private ListView<Event> eventListView;
    
        @FXML
        private TextFlow eventInfoTextFlow;
    
        @FXML
        private void initialize() {
            eventListView.getSelectionModel().selectedItemProperty().addListener((observable, previouslySelectedEvent, selectedEvent) ->
                    displayEventDetail(selectedEvent)
            );
        }
    
        private void displayEventDetail(Event event) {
            eventInfoTextFlow.getChildren().clear();
    
            if (event == null) {
                return;
            }
    
            Text eventTitle = new Text(event.getTitle() + "\n");
            eventTitle.setFont(Font.font("System", 25));
    
            Text eventDescription = new Text(event.getDescription());
            eventDescription.setFont(Font.font("System", 20));
    
            eventInfoTextFlow.getChildren().addAll(eventTitle, eventDescription);
            eventInfoTextFlow.setLineSpacing(5.0);
        }
    
        @FXML
        private void onCreateEvent() {
            Dialog<Event> eventDialog = new EventDialog(new Event());
    
            Optional<Event> optionalEvent = eventDialog.showAndWait();
            if (optionalEvent.isPresent()) {
                Event event = optionalEvent.get();
                eventListView.getItems().add(event);
                eventListView.getSelectionModel().select(event);
            }
        }
    }
    

    Main.fxml

    <?xml version="1.0" encoding="UTF-8"?>
    
    <?import javafx.geometry.Insets?>
    <?import javafx.scene.control.Button?>
    <?import javafx.scene.control.ListView?>
    <?import javafx.scene.layout.HBox?>
    <?import javafx.scene.layout.VBox?>
    <?import javafx.scene.text.TextFlow?>
    
    <HBox maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" prefHeight="400.0" prefWidth="600.0" xmlns="http://javafx.com/javafx/19" xmlns:fx="http://javafx.com/fxml/1" fx:controller="com.test.demo.login.MainController">
        <children>
            <VBox prefHeight="400.0" prefWidth="300.0" HBox.hgrow="ALWAYS">
                <children>
                    <ListView fx:id="eventListView" VBox.vgrow="ALWAYS" />
                </children>
            </VBox>
            <VBox prefHeight="400.0" prefWidth="300.0" HBox.hgrow="ALWAYS">
                <children>
                    <TextFlow fx:id="eventInfoTextFlow" VBox.vgrow="ALWAYS">
                        <padding>
                            <Insets bottom="10.0" left="10.0" right="10.0" top="10.0" />
                        </padding>
                    </TextFlow>
                    <HBox alignment="CENTER_LEFT" prefHeight="50.0" VBox.vgrow="NEVER">
                        <padding>
                            <Insets bottom="5.0" left="5.0" right="5.0" top="5.0" />
                        </padding>
                        <children>
                            <Button mnemonicParsing="false" onAction="#onCreateEvent" prefHeight="36.0" prefWidth="109.0" text="Create Event" />
                        </children>
                    </HBox>
                </children>
            </VBox>
        </children>
    </HBox>
    

    FAQ

    Is your code the best practice/recommended way for working with ListView?

    It depends.

    For a simple application, such as this one currently, then yes, I would say the solution provided in this answer is a best practice. This is because the ListView already has exactly the right abstractions that are needed, it maintains a list of items backing the list and maintains a selection model for the selected item in the list. You don't have to (and should not) reinvent that logic in your own code.

    If the application were more complex, then you might change the architecture a bit. In particular, you could add an MVC design where you have a model class with an ObservableList of events and an ObjectProperty defining the current event, but no UI logic at all.

    With MVC, you could perform a bidirectional binding of the event list in your model with the items in the ListView and also a bidirectional binding of the selected event in the ListView with the current event in your model. But that is only needed if you need that info elsewhere in the application. For instance, if another, completely unrelated, FXML page works with the current event it gets from the model by also binding to it, knowing nothing at all about the ListView and the controller the ListView is encapsulated in.

    The concept of encapsulating the UI and encapsulating the data is different. Rather than linking both the data structure and the UI as you do in your EventBoard implementation, you can keep them separate, that way the UI can change without changing the model structure and other, non-UI elements can use the model data without having any dependence on the UI. Then everything can be developed and tested separately, for instance, a persistence service or layer that persisted model data to a database or file system could be coded independently of the UI.

    But, for your application, which is simple so far, those concerns don't really apply, so it is better not to prematurely complicate and abstract the application by adding an MVC layer.

    which line(s) in particular were causing the bug, so that I can avoid the bug in the future.

    I didn't debug your code in detail to try to find your issues, it was quicker and easier just to refactor it. Sometimes it is a better idea to re-evaluate and rework the design than to debug a design with issues (I found this to be the case here).

    One obvious issue is using the onMouseClick handler to try to capture selection changes in the ListView. This is simply wrong because the selection can change in numerous ways unrelated to mouse clicks (e.g. keyboard input or programmatic changes to the selection model).

    An over-complication of your original design is trying to maintain separate lists and selection handling in the EventBoard and having a complex UI implemented in that class. Sometimes you want componentized UI, where that approach might be a good idea. For example, if you have a reusable UI component that will be used in multiple places in your UI or in other applications or library code. But if you don't have such a setup, the extra complexity and overhead in creating a componentized reusable component is usually not worth it and potentially error-prone.

    In this case, it was better to handle all of the UI logic directly in the FXML controller, where all of the required UI elements and events were already well-defined and encapsulated.