0
votes

The current implementation of the VirtualFlow only makes scrollbars visible when view rect becomes less than control size. By control I mean ListView, TreeView and whatever standard virtualized controls. The problem is that vertical scrollbar appearance causes recalculation of the control width, namely it slightly shifts cell content to the left side. This is clearly noticeable and very uncomfortable movement.

I need to reserve some space for the vertical scrollbar beforehand, but none of controls provide API to manipulate VirtualFlow scrollbars behavior, which is very unfortunate API design. Not to mention that most of the implementations place scrollbars on top of the component, thus just overlapping the small part of it.

The question is, "Which is the best way to achieve this?". Paddings won't help, and JavaFX has no margins support. I could put control (e.g ListView) inside of ScrollPane, but I'd bet VirtualFlow won't continue to reuse cells in that case, so it's not a solution.

EXAMPLE:

Expand and collapse node2, it shifts lbRight content.

public class Launcher extends Application {

    @Override
    public void start(Stage primaryStage) throws Exception {
        TreeItem<UUID> root = new TreeItem<>(UUID.randomUUID());

        TreeView<UUID> tree = new TreeView<>(root);
        tree.setCellFactory(list -> new CustomCell());

        TreeItem<UUID> node0 = new TreeItem<>(UUID.randomUUID());
        TreeItem<UUID> node1 = new TreeItem<>(UUID.randomUUID());
        TreeItem<UUID> node2 = new TreeItem<>(UUID.randomUUID());
        IntStream.range(0, 100)
                .mapToObj(index -> new TreeItem<>(UUID.randomUUID()))
                .forEach(node2.getChildren()::add);

        root.getChildren().setAll(node0, node1, node2);
        root.setExpanded(true);
        node2.setExpanded(true);

        BorderPane pane = new BorderPane();
        pane.setCenter(tree);

        Scene scene = new Scene(pane, 600, 600);
        primaryStage.setTitle("Demo");
        primaryStage.setScene(scene);
        primaryStage.setOnCloseRequest(t -> Platform.exit());
        primaryStage.show();
    }

    static class CustomCell extends TreeCell<UUID> {

        public HBox hBox;
        public Label lbLeft;
        public Label lbRight;

        public CustomCell() {
            hBox = new HBox();
            lbLeft = new Label();
            lbRight = new Label();
            lbRight.setStyle("-fx-padding: 0 20 0 0");

            Region spacer = new Region();
            HBox.setHgrow(spacer, Priority.ALWAYS);

            hBox.getChildren().setAll(lbLeft, spacer, lbRight);
        }

        @Override
        protected void updateItem(UUID uuid, boolean empty) {
            super.updateItem(uuid, empty);

            if (empty) {
                setGraphic(null);
                return;
            }

            String s = uuid.toString();
            lbLeft.setText(s.substring(0, 6));
            lbRight.setText(s.substring(6, 12));
            setGraphic(hBox);
        }
    }
}
2
as always when custom requirements are not met: implement it yourself :) Here you would need a custom skin using a custom virtualflow. Anyway, first step is to specify exactly what you want, clearly thinking about constraints.kleopatra
vertical scrollbar appearance causes recalculation of the control width really? how about an example of what you mean?kleopatra
@kleopatra It's hardly custom. It's the only reasonable behavior any other major UI toolkit implements by default ¯\_(ツ)_/¯ Just notice how Qt or GTK widgets display scrollbars. It's always on top. As about your suggestion, well... thank you... but it's very tricky to implement.enzo
doesn't matter too much what other frameworks do, does it ;) I meant custom as in not-provided by fx - they had a reason to implement as they did. And keeping on top (thus potentially hiding cell content) is not inherently better, just different IMO. Yeah, I'm aware it's not simple to change VirtualFlow, have been there ;) Anyway, you still didn't specify exactly what you want. Without, this question isn't answerable in the scope of this site.kleopatra
.. as-is, it sounds more like a rant about what you think is missing: provide API to manipulate VirtualFlow scrollbars behavior, which is very unfortunate API design .. well, maybe or not .. the path to go: specify the api you want and implement it yourself and/or file an enhancement issue, contributing what you implemented :)kleopatra

2 Answers

1
votes

Reacting to

you can't just extend the VirtualFlow and override a method

certainly true if the method is deeply hidden by package/-private access (but even then: javafx is open source, checkout-edit-compile-distribute is also an option :). In this case we might get along with overriding public api as outlined below (not formally tested!).

VirtualFlow is the "layout" of cells and scrollBars: in particular, it has to cope with handling sizing/locating of all content w/out scrollBars being visible. There are options on how that can be done:

  • adjust cell width to always fill the viewport, increasing/decreasing when vertical scrollBar is hidden/visible
  • keep cell width constant such that there is always space left for the scrollBar, be it visible or not
  • keep cell width constant such that there is never space left the scrollBar, laying it out on top of cell
  • others ??

Default VirtualFlow implements the first with no option to switch to any other. (might be candidate for an RFE, feel free to report :).

Digging into the code reveals that the final sizing of the cells is done by calling cell.resize(..) (as already noted and exploited in the self-answer) near the end of the layout code. Overriding a custom cell's resize is perfectly valid and a good option .. but not the only one, IMO. An alternative is to

  • extend VirtualFlow and override layoutChildren to adjust cell width as needed
  • extend TreeViewSkin to use the custom flow

Example code (requires fx12++):

public static class XVirtualFlow<I extends IndexedCell> extends VirtualFlow<I> {

    @Override
    protected void layoutChildren() {
        super.layoutChildren();
        fitCellWidths();
    }

    /**
     * Resizes cell width to accomodate for invisible vbar.
     */
    private void fitCellWidths() {
        if (!isVertical() || getVbar().isVisible()) return;
        double width = getWidth() - getVbar().getWidth();
        for (I cell : getCells()) {
            cell.resize(width, cell.getHeight());
        }
    }
    
}

public static class XTreeViewSkin<T> extends TreeViewSkin<T>{

    public XTreeViewSkin(TreeView<T> control) {
        super(control);
    }

    @Override
    protected VirtualFlow<TreeCell<T>> createVirtualFlow() {
        return new XVirtualFlow<>();
    }
    
}

On-the-fly usage:

TreeView<UUID> tree = new TreeView<>(root) {

    @Override
    protected Skin<?> createDefaultSkin() {
        return new XTreeViewSkin<>(this);
    }
    
};
0
votes

Ok, this is summary based on @kleopatra comments and OpenJFX code exploration. There will be no code to solve the problem, but still maybe it will spare some time to someone.

As being said, it's VirtualFlow responsibility to manage virtualized control viewport size. All magic happens in the layoutChildren(). First it computes scrollbars visibility and then recalculates size of all children based on that knowledge. Here is the code which causes the problem.

Since all implementation details are private or package-private, you can't just extend the VirtualFlow and override method or two, you have to copy-paste and edit entire class (to remove one line, yes). Given that, changing internal components layout could be a better option.

Sometimes, I adore languages those have no encapsulation.

UPDATE:

I've solved the problem. There is no way no reserve space for vertical scrollbar without tweaking JavaFX internals, but we can limit cell width, so it would be always less than TreeView (or List View) width. Here is simple example.

public class Launcher extends Application {

    public static final double SCENE_WIDTH = 500;

    @Override
    public void start(Stage primaryStage) {
        TreeItem<UUID> root = new TreeItem<>(UUID.randomUUID());

        TreeView<UUID> tree = new TreeView<>(root);
        tree.setCellFactory(list -> new CustomCell(SCENE_WIDTH));

        TreeItem<UUID> node0 = new TreeItem<>(UUID.randomUUID());
        TreeItem<UUID> node1 = new TreeItem<>(UUID.randomUUID());
        TreeItem<UUID> node2 = new TreeItem<>(UUID.randomUUID());
        IntStream.range(0, 100)
                .mapToObj(index -> new TreeItem<>(UUID.randomUUID()))
                .forEach(node2.getChildren()::add);

        root.getChildren().setAll(node0, node1, node2);
        root.setExpanded(true);
        node2.setExpanded(true);

        BorderPane pane = new BorderPane();
        pane.setCenter(tree);

        Scene scene = new Scene(pane, SCENE_WIDTH, 600);

        primaryStage.setTitle("Demo");
        primaryStage.setScene(scene);
        primaryStage.setOnCloseRequest(t -> Platform.exit());
        primaryStage.show();
    }

    static class CustomCell extends TreeCell<UUID> {

        public static final double RIGHT_PADDING = 40;

        /*
          this value depends on tree disclosure node width
          in my case it's enforced via CSS, so I always know exact
          value of this padding
        */
        public static final double INDENT_PADDING = 14;

        public HBox hBox;
        public Label lbLeft;
        public Label lbRight;

        public double maxWidth;

        public CustomCell(double maxWidth) {
            this.maxWidth = maxWidth;

            hBox = new HBox();
            lbLeft = new Label();
            lbRight = new Label();
            lbRight.setPadding(new Insets(0, RIGHT_PADDING, 0, 0));

            Region spacer = new Region();
            HBox.setHgrow(spacer, Priority.ALWAYS);

            hBox.getChildren().setAll(lbLeft, spacer, lbRight);
        }

        @Override
        protected void updateItem(UUID uuid, boolean empty) {
            super.updateItem(uuid, empty);

            if (empty) {
                setGraphic(null);
                return;
            }

            String s = uuid.toString();
            lbLeft.setText(s.substring(0, 6));
            lbRight.setText(s.substring(6, 12));

            setGraphic(hBox);
        }

        @Override
        public void resize(double width, double height) {
            // enforce item width
            double maxCellWidth = getTreeView().getWidth() - RIGHT_PADDING;
            double startLevel = getTreeView().isShowRoot() ? 0 : 1;
            double itemLevel = getTreeView().getTreeItemLevel(getTreeItem());
            if (itemLevel > startLevel) {
                maxCellWidth = maxCellWidth - ((itemLevel - startLevel) * INDENT_PADDING);
            }

            hBox.setPrefWidth(maxCellWidth);
            hBox.setMaxWidth(maxCellWidth);

            super.resize(width, height);
        }
    }
}

It's far from perfect, but it works.