4

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>
jewelsea
  • 150,031
  • 14
  • 366
  • 406
BenT
  • 111
  • 9
  • A clip is the easiest solution as it will prevent the overflow of text, at least visually. However, `Text` does not provide a way to automatically fall back to an ellipse if the text is too long. That sort of behavior is implemented specially by `Labeled` and its subclasses. – Slaw Jul 26 '21 at 11:05
  • I tried clipping (like in https://stackoverflow.com/questions/59705403/javafx-textflow-hide-overrun-text-clip-or-ellipsis), but it did not work. I'll integrate it in the MWE and check again. – BenT Jul 26 '21 at 11:18
  • Implemented in https://github.com/btut/TextFlowMWE/tree/rectClip, but the result is even worse. – BenT Jul 26 '21 at 11:30
  • [mcve] please .. here, not anywhere external :) – kleopatra Jul 26 '21 at 12:13
  • 1
    Here you go! Thanks for your help! – BenT Jul 26 '21 at 12:50
  • 2
    One tip, even for a minimal example, ***never*** make an application class also a controller class. You should only have a single application instance for your application. When the application is also a controller, new instances will be created as fxml is loaded, which often means that subtle and difficult to debug issues can occur. Besides, it is a violation of the [single responsibility principle](https://en.wikipedia.org/wiki/Single-responsibility_principle). – jewelsea Jul 26 '21 at 23:08
  • 2
    I don't advise trying to solve this using a GridPane or FlowPane, which are more complex layout controls (I tried and failed to do it quickly). Put the nodes (only TextFlow nodes, don't mix in labels) in a simpler layout manager like a VBox and try solving the problem with just that. Getting the ellipsis logic will likely be extremely hard, so I don't advise trying to do that, just try getting overruns clipped, even then it may be tricky. – jewelsea Jul 27 '21 at 00:14
  • @jewelsea thanks. As you suspected I only omitted the controller in the MWE, bt your reasoning makes sense. I'll keep that in mind! – BenT Jul 27 '21 at 04:58
  • @jewelsea I need a GridPane for my actual implementation. Would wrapping the TextFlow in another container that is placed in the GridPane be enough? – BenT Jul 27 '21 at 04:59
  • Solving it with a `GridPane` is likely going to be more complicated because `GridPane`s are more complicated. Try solving it with a `VBox` first and if you get that working, then try integrating with a `GridPane`, just my opinion. And no, don't add even more complexity by wrapping in another layout pane and placing that in a `GridPane`, until you know how to solve this with just a `VBox`. – jewelsea Jul 27 '21 at 19:15

1 Answers1

5

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;
    }
}
BenT
  • 111
  • 9
  • 1
    Wow, that's complicated, but it looks like it works for you, so good :-) Perhaps another way it could have been done is by overriding the `layoutChildren()` method which is an alternate way to performing layout via bindings and listeners (internally pretty much all JavaFX controls override `layoutChildren()` to perform their layout functions. – jewelsea Jul 27 '21 at 19:21
  • That sounds like a more sophisticated solution, I'll see if I can get that working. Thanks again for your Tips! – BenT Jul 28 '21 at 20:04