Search code examples
javafxtextflow

How to let JavaFX TextFlow automatically cut a long text?


I want to display some formatted text using TextFlow. Previousely, I used a simple Label (with wrapText set to true) to display that text (unformatted), but want to make use of a Library that provides a List of Texts that I would like to display using a TextFlow.

My problem is that the text I want to display is larger than the available Area. Labels cut off the text when running out of space. This works great. Unfortunately TextFlow does not. When the text gets too long, it overflows the Region the TextFlow is in. Neighboring TextFlows then overlap each other. How can I mimic the behavior of the Label?

An MWE can be found here and below. I use a GridPane with two columns. Three TextFlows on the left, three Labels at the right. The displayed text is the same for all six elements. It produces this window:

enter image description here

As you can see, the text on the left (in the TextFlows) overlaps.

I tried, without success:

  • Setting the maxWidth and maxHeight of the TextFlow to the available Area
  • Creating a rectangle of appropriate size and setting it as a clip

JAVA:

package sample;

import javafx.application.Application;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import javafx.stage.Stage;

public class Main extends Application {

    @FXML
    private TextFlow textFlow0;
    @FXML
    private TextFlow textFlow1;
    @FXML
    private TextFlow textFlow2;
    @FXML
    private Label label0;
    @FXML
    private Label label1;
    @FXML
    private Label label2;

    private String longText = "This is some really long text that should overflow the available Area. " +
            "For TextFields, this is handeled by cropping the text to appropriate length and adding \"...\" at the end. " +
            "No such option exists for TextFlows";

    @Override
    public void start(Stage primaryStage) throws Exception{
        Parent root = FXMLLoader.load(getClass().getResource("sample.fxml"));
        primaryStage.setTitle("Text Overflow");
        primaryStage.setScene(new Scene(root, 300, 275));
        primaryStage.show();
    }

    @FXML
    private void initialize() {
        textFlow0.getChildren().add(new Text(longText));
        textFlow1.getChildren().add(new Text(longText));
        textFlow2.getChildren().add(new Text(longText));
        label0.setText(longText);
        label1.setText(longText);
        label2.setText(longText);
    }


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

FXML:

<?import javafx.scene.control.Label?>
<GridPane fx:controller="sample.Main"
          xmlns:fx="http://javafx.com/fxml" alignment="center" hgap="10" vgap="10">
    <TextFlow fx:id="textFlow0" GridPane.rowIndex = "0" GridPane.columnIndex="0" />
    <Label fx:id="label0" GridPane.rowIndex = "0" wrapText="true" GridPane.columnIndex="1"/>
    <TextFlow fx:id="textFlow1" GridPane.rowIndex = "1" GridPane.columnIndex="0" />
    <Label fx:id="label1" GridPane.rowIndex = "1" wrapText="true" GridPane.columnIndex="1"/>
    <TextFlow fx:id="textFlow2" GridPane.rowIndex = "2" GridPane.columnIndex="0" />
    <Label fx:id="label2" GridPane.rowIndex = "2" wrapText="true" GridPane.columnIndex="1"/>
</GridPane>

Failed: attempt to use clip

package sample;

import javafx.application.Application;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.layout.FlowPane;
import javafx.scene.shape.Rectangle;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import javafx.stage.Stage;

public class Main extends Application {

    @FXML
    private FlowPane flowPane;

    @FXML
    private TextFlow textFlow0;
    @FXML
    private TextFlow textFlow1;
    @FXML
    private TextFlow textFlow2;
    @FXML
    private Label label0;
    @FXML
    private Label label1;
    @FXML
    private Label label2;

    private String longText = "This is some really long text that should overflow the available Area. " +
            "For TextFields, this is handeled by cropping the text to appropriate length and adding \"...\" at the end. " +
            "No such option exists for TextFlows";

    @Override
    public void start(Stage primaryStage) throws Exception{
        Parent root = FXMLLoader.load(getClass().getResource("sample.fxml"));
        primaryStage.setTitle("Text Overflow");
        primaryStage.setScene(new Scene(root, 300, 275));
        primaryStage.show();
    }

    @FXML
    private void initialize() {
        flowPane.setPrefWrapLength(Double.MAX_VALUE);
        Rectangle rect = new Rectangle();
        rect.widthProperty().bind(flowPane.widthProperty());
        rect.heightProperty().bind(flowPane.heightProperty());
        flowPane.setClip(rect);
        textFlow0.getChildren().add(new Text(longText));
        textFlow1.getChildren().add(new Text(longText));
        textFlow2.getChildren().add(new Text(longText));
        label0.setText(longText);
        label1.setText(longText);
        label2.setText(longText);
    }


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

FXML file for clip attempt

<?import javafx.scene.layout.GridPane?>
<?import javafx.scene.text.TextFlow?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.layout.FlowPane?>
<GridPane fx:controller="sample.Main"
          xmlns:fx="http://javafx.com/fxml" alignment="center" hgap="10" vgap="10">
    <FlowPane fx:id="flowPane" GridPane.rowIndex = "0" GridPane.columnIndex="0">
        <TextFlow fx:id="textFlow0" />
    </FlowPane>
    <Label fx:id="label0" GridPane.rowIndex = "0" wrapText="true" GridPane.columnIndex="1"/>
    <TextFlow fx:id="textFlow1" GridPane.rowIndex = "1" GridPane.columnIndex="0" />
    <Label fx:id="label1" GridPane.rowIndex = "1" wrapText="true" GridPane.columnIndex="1"/>
    <TextFlow fx:id="textFlow2" GridPane.rowIndex = "2" GridPane.columnIndex="0" />
    <Label fx:id="label2" GridPane.rowIndex = "2" wrapText="true" GridPane.columnIndex="1"/>
</GridPane>

Solution

  • As there seems to be no built-in way to do this, I implemented my own. It's probably not the most efficient way to tackle this problem, but satisfies my use-case pretty well. If better solutions pop up in the next days, I will accept one of them. If not, I will select this answer as accepted.

    enter image description here

    There still is one problem: I need to click the window once for the text to show up in the beginning. Also, there is one major problem: What to do if a child node is not a Text object?

    package sample;
    
    import javafx.beans.DefaultProperty;
    import javafx.beans.property.StringProperty;
    import javafx.beans.value.ChangeListener;
    import javafx.collections.FXCollections;
    import javafx.collections.ListChangeListener;
    import javafx.collections.ObservableList;
    import javafx.scene.Node;
    import javafx.scene.text.Text;
    import javafx.scene.text.TextFlow;
    
    @DefaultProperty("children")
    public class EllipsingTextFlow extends TextFlow {
    
        private final static String DEFAULT_ELLIPSIS_STRING = "...";
        private StringProperty ellipsisString;
    
        //private ListProperty<Node> allChildren = new SimpleListProperty<Node>(new SimpleObs<Node>());
        private ObservableList<Node> allChildren = FXCollections.observableArrayList();
        private ChangeListener sizeChangeListener = (observableValue, number, t1) -> adjustText();
    
        public EllipsingTextFlow() {
            allChildren.addListener((ListChangeListener<Node>) this::adjustChildren);
            widthProperty().addListener(sizeChangeListener);
            heightProperty().addListener(sizeChangeListener);
            adjustText();
        }
    
        @Override
        public ObservableList<Node> getChildren() {
            return allChildren;
        }
    
        private void adjustChildren(ListChangeListener.Change<? extends Node> change) {
            while (change.next()) {
                if (change.wasRemoved()) {
                    super.getChildren().remove(change.getFrom(), change.getTo());
                } else if (change.wasAdded()) {
                    super.getChildren().addAll(change.getFrom(), change.getAddedSubList());
                }
            }
            adjustText();
        }
    
        private void adjustText() {
            // remove listeners
            widthProperty().removeListener(sizeChangeListener);
            heightProperty().removeListener(sizeChangeListener);
            while (getHeight() > getMaxHeight() || getWidth() > getMaxWidth()) {
                if (super.getChildren().isEmpty()) {
                    // nothing fits
                    widthProperty().addListener(sizeChangeListener);
                    heightProperty().addListener(sizeChangeListener);
                    return;
                }
                super.getChildren().remove(super.getChildren().size()-1);
                super.autosize();
            }
            while (getHeight() <= getMaxHeight() && getWidth() <= getMaxWidth()) {
                if (super.getChildren().size() == allChildren.size()) {
                    if (allChildren.size() > 0) {
                        // all Texts are displayed, let's make sure all chars are as well
                        Node lastChildAsShown = super.getChildren().get(super.getChildren().size() - 1);
                        Node lastChild = allChildren.get(allChildren.size() - 1);
                        if (lastChildAsShown instanceof Text && ((Text) lastChildAsShown).getText().length() < ((Text) lastChild).getText().length()) {
                            ((Text) lastChildAsShown).setText(((Text) lastChild).getText());
                        } else {
                            // nothing to fill the space with
                            widthProperty().addListener(sizeChangeListener);
                            heightProperty().addListener(sizeChangeListener);
                            return;
                        }
                    }
                } else {
                    super.getChildren().add(allChildren.get(super.getChildren().size()));
                }
                super.autosize();
            }
            // ellipse the last text as much as necessary
            while (getHeight() > getMaxHeight() || getWidth() > getMaxWidth()) {
                Node lastChildAsShown = super.getChildren().remove(super.getChildren().size()-1);
                while (getEllipsisString().equals(((Text) lastChildAsShown).getText())) {
                    if (super.getChildren().size() == 0) {
                        widthProperty().addListener(sizeChangeListener);
                        heightProperty().addListener(sizeChangeListener);
                        return;
                    }
                    lastChildAsShown = super.getChildren().remove(super.getChildren().size() -1);
                }
                if (lastChildAsShown instanceof Text && ((Text) lastChildAsShown).getText().length() > 0) {
                    // Text shortenedChild = new Text(((Text) lastChildAsShown).getText().substring(0, ((Text) lastChildAsShown).getText().length()-1));
                    Text shortenedChild = new Text(ellipseString(((Text) lastChildAsShown).getText()));
                    super.getChildren().add(shortenedChild);
                } else {
                    // don't know what to do with anything else. Leave without adding listeners
                    return;
                }
                super.autosize();
            }
            widthProperty().addListener(sizeChangeListener);
            heightProperty().addListener(sizeChangeListener);
        }
    
        private String ellipseString(String s) {
            int spacePos = s.lastIndexOf(' ');
            if (spacePos < 0) {
                return getEllipsisString();
            }
            return s.substring(0, spacePos) + getEllipsisString();
        }
    
        public final void setEllipsisString(String value) {
            ellipsisString.set((value == null) ? "" : value);
        }
    
        public String getEllipsisString() {
            return ellipsisString == null ? DEFAULT_ELLIPSIS_STRING : ellipsisString.get();
        }
    
        public final StringProperty ellipsisStringProperty(){
            return ellipsisString;
        }
    }