TreeView 中的 JavaFX 布局问题

Sai*_*dem 5 javafx javafx-8 javafx-18

最近,我将应用程序从 JavaFX 8 更新到 JavaFX 18。迁移后,我发现了一些与TreeView. 如果我理解正确的话,在场景脉冲结束时,所有节点(实际上是父节点)都将完全渲染,并且变为isNeedsLayoutfalse,直到发生下一次更改。

这在 JavaFX 8 中按预期工作。而在 JavaFX 18 中,对于某些节点,即使在脉冲完成后isNeedsLayout标志仍然存在。true这是一个错误吗?还是故意这么实施的?

在下面的演示中,我尝试TreeView在脉冲完成后打印所有节点( 的子节点)状态。我可以清楚地看到两个 JavaFX 版本之间的输出差异。

谁能告诉我,如何确保所有节点都正确渲染/布局。

import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.application.Application;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.control.TreeCell;
import javafx.scene.control.TreeItem;
import javafx.scene.control.TreeView;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;
import javafx.util.Duration;

import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class TreeViewLayoutIssue extends Application {
    int k = 1;

    @Override
    public void start(Stage primaryStage) throws Exception {
        final TreeView<String> fxTree = new TreeView<>();
        fxTree.setCellFactory(t -> new TreeCell<String>() {

            Label lbl = new Label();

            @Override
            protected void updateItem(String item, boolean empty) {
                super.updateItem(item, empty);
                setText(null);
                if (item != null) {
                    lbl.setText(item);
                    setGraphic(lbl);
                } else {
                    setGraphic(null);
                }
            }

            @Override
            protected void layoutChildren() {
                super.layoutChildren();
                if (getItem() != null) {
                    System.out.println("Layouting ::> " + getItem());
                }
            }
        });
        fxTree.setShowRoot(false);

        StackPane root = new StackPane(fxTree);
        root.setPadding(new Insets(15));
        final Scene scene = new Scene(root, 250, 250);
        scene.getStylesheets().add(this.getClass().getResource("treeview.css").toExternalForm());
        primaryStage.setTitle("TreeView FX18");
        primaryStage.setScene(scene);
        primaryStage.show();
        addData(fxTree);
        
        final Timeline timeline = new Timeline(new KeyFrame(Duration.millis(2000), e -> {
            System.out.println("\nIteration #" + k++);
            printNeedsLayout(fxTree);
            System.out.println("-----------------------------------------------------------------------------");
        }));
        timeline.setCycleCount(3);
        timeline.play();
    }

    private void printNeedsLayout(final Parent parent) {
        System.out.println("  " + parent + " isNeedsLayout: " + parent.isNeedsLayout());
        for (final Node n : parent.getChildrenUnmodifiable()) {
            if (n instanceof Parent) {
                printNeedsLayout((Parent) n);
            }
        }
    }

    private void addData(TreeView<String> fxTree) {
        final TreeItem<String> rootNode = new TreeItem<>("");
        fxTree.setRoot(rootNode);
        final TreeItem<String> grp1Node = new TreeItem<>("Group 1");
        final TreeItem<String> grp2Node = new TreeItem<>("Group 2");
        rootNode.getChildren().addAll(grp1Node, grp2Node);

        final TreeItem<String> subNode = new TreeItem<>("Team");
        grp1Node.getChildren().addAll(subNode);

        final List<TreeItem<String>> groups = Stream.of("Red", "Green", "Yellow", "Blue").map(TreeItem::new).collect(Collectors.toList());
        groups.forEach(itm -> subNode.getChildren().add(itm));

        grp1Node.setExpanded(true);
        grp2Node.setExpanded(true);
        subNode.setExpanded(true);
    }
}
Run Code Online (Sandbox Code Playgroud)

树视图.css

.virtual-flow .clipped-container .sheet .tree-cell .tree-disclosure-node > .arrow {
  -fx-background-color: #77797a;
}

.virtual-flow .clipped-container .sheet .tree-cell .tree-disclosure-node {
  -fx-padding: 5px 6px 3px 8px; /* default is 4px 6px 4px 8px */
}

.virtual-flow .clipped-container .sheet .tree-cell:expanded > .tree-disclosure-node {
  -fx-padding: 7px 6px 1px 8px; /* default is 4px 6px 4px 8px */
}

.virtual-flow .clipped-container .sheet .tree-cell:selected > .tree-disclosure-node > .arrow {
  -fx-background-color: #f7f7f7;
}
Run Code Online (Sandbox Code Playgroud)

输出如下:

首次选择节点时,在 FX18 中观察到文本略有变化。而FX8则没有问题。

在此输入图像描述

输出 (JavaFX 8):所有节点的 isNeedsLayout 均为 false。

  TreeView@7abc5bb4[styleClass=tree-view] isNeedsLayout: false
  VirtualFlow[id=virtual-flow, styleClass=virtual-flow] isNeedsLayout: false
  VirtualFlow$ClippedContainer@187e8c80[styleClass=clipped-container] isNeedsLayout: false
  Group@57d7468f[styleClass=sheet] isNeedsLayout: false
  TreeViewLayoutIssue$1@11a5c9a9[styleClass=cell indexed-cell tree-cell]'Group 1' isNeedsLayout: false
  StackPane@7c0429[styleClass=tree-disclosure-node] isNeedsLayout: false
  StackPane@731322a7[styleClass=arrow] isNeedsLayout: false
  TreeViewLayoutIssue$1@198e401a[styleClass=cell indexed-cell tree-cell]'Team' isNeedsLayout: false
  StackPane@e2e9b3[styleClass=tree-disclosure-node] isNeedsLayout: false
  StackPane@18615368[styleClass=arrow] isNeedsLayout: false
  TreeViewLayoutIssue$1@1632f423[styleClass=cell indexed-cell tree-cell]'Red' isNeedsLayout: false
  TreeViewLayoutIssue$1@1f706faa[styleClass=cell indexed-cell tree-cell]'Green' isNeedsLayout: false
  TreeViewLayoutIssue$1@2fc143cc[styleClass=cell indexed-cell tree-cell]'Yellow' isNeedsLayout: false
  TreeViewLayoutIssue$1@6cd95e88[styleClass=cell indexed-cell tree-cell]'Blue' isNeedsLayout: false
  TreeViewLayoutIssue$1@1d2b9016[styleClass=cell indexed-cell tree-cell]'Group 2' isNeedsLayout: false
  TreeViewLayoutIssue$1@7ed38c68[styleClass=cell indexed-cell tree-cell]'null' isNeedsLayout: false
  TreeViewLayoutIssue$1@4a73e93a[styleClass=cell indexed-cell tree-cell]'null' isNeedsLayout: false
  TreeViewLayoutIssue$1@2a65b6dd[styleClass=cell indexed-cell tree-cell]'null' isNeedsLayout: false
  Group@2c481877 isNeedsLayout: false
  TreeViewLayoutIssue$1@1621d92f[styleClass=cell indexed-cell tree-cell]'null' isNeedsLayout: false
  VirtualScrollBar@9a98f02[styleClass=scroll-bar] isNeedsLayout: false
  StackPane@343dbe88[styleClass=track-background] isNeedsLayout: false
  ScrollBarSkin$2@4727ac31[styleClass=increment-button] isNeedsLayout: false
  Region@6d0e770[styleClass=increment-arrow] isNeedsLayout: false
  ScrollBarSkin$3@201819d7[styleClass=decrement-button] isNeedsLayout: false
  Region@38d26811[styleClass=decrement-arrow] isNeedsLayout: false
  StackPane@2e0a5131[styleClass=track] isNeedsLayout: false
  ScrollBarSkin$1@771a6386[styleClass=thumb] isNeedsLayout: false
  VirtualScrollBar@3177bd8a[styleClass=scroll-bar] isNeedsLayout: false
  StackPane@39ae89a6[styleClass=track-background] isNeedsLayout: false
  ScrollBarSkin$2@3393f75c[styleClass=increment-button] isNeedsLayout: false
  Region@20002045[styleClass=increment-arrow] isNeedsLayout: false
  ScrollBarSkin$3@1230eb63[styleClass=decrement-button] isNeedsLayout: false
  Region@5dcf6e46[styleClass=decrement-arrow] isNeedsLayout: false
  StackPane@342b0a3d[styleClass=track] isNeedsLayout: false
  ScrollBarSkin$1@798546a7[styleClass=thumb] isNeedsLayout: false
  StackPane@7094a28f[styleClass=corner] isNeedsLayout: false
Run Code Online (Sandbox Code Playgroud)

输出(JavaFX 18):某些节点的 isNeedsLayout 仍然为 true。

  TreeView@6d663ddb[styleClass=tree-view] isNeedsLayout: true
  VirtualFlow[id=virtual-flow, styleClass=virtual-flow] isNeedsLayout: false
  VirtualFlow$ClippedContainer@25755334[styleClass=clipped-container] isNeedsLayout: false
  Group@5ec62b29[styleClass=sheet] isNeedsLayout: true
  TreeViewLayoutIssue$1@61b62840[styleClass=cell indexed-cell tree-cell]'null' isNeedsLayout: false
  TreeViewLayoutIssue$1@1995039d[styleClass=cell indexed-cell tree-cell]'null' isNeedsLayout: false
  TreeViewLayoutIssue$1@5401ae6f[styleClass=cell indexed-cell tree-cell]'null' isNeedsLayout: false
  TreeViewLayoutIssue$1@2d51271e[styleClass=cell indexed-cell tree-cell]'Group 2' isNeedsLayout: true
  TreeViewLayoutIssue$1@500b9275[styleClass=cell indexed-cell tree-cell]'Blue' isNeedsLayout: true
  TreeViewLayoutIssue$1@342e3899[styleClass=cell indexed-cell tree-cell]'Yellow' isNeedsLayout: true
  TreeViewLayoutIssue$1@68c87749[styleClass=cell indexed-cell tree-cell]'Green' isNeedsLayout: true
  TreeViewLayoutIssue$1@18ed5cce[styleClass=cell indexed-cell tree-cell]'Red' isNeedsLayout: true
  TreeViewLayoutIssue$1@2f10a091[styleClass=cell indexed-cell tree-cell]'Team' isNeedsLayout: true
  StackPane@16cb362[styleClass=tree-disclosure-node] isNeedsLayout: true
  StackPane@7b6a2594[styleClass=arrow] isNeedsLayout: false
  TreeViewLayoutIssue$1@70c30d4[styleClass=cell indexed-cell tree-cell]'Group 1' isNeedsLayout: true
  StackPane@698dbc70[styleClass=tree-disclosure-node] isNeedsLayout: true
  StackPane@45cafbcd[styleClass=arrow] isNeedsLayout: false
  Group@461a2cd9 isNeedsLayout: false
  VirtualScrollBar@13f48448[styleClass=scroll-bar] isNeedsLayout: false
  StackPane@1dea4632[styleClass=track-background] isNeedsLayout: false
  ScrollBarSkin$2@6acd8e11[styleClass=increment-button] isNeedsLayout: false
  Region@5ab4b98c[styleClass=increment-arrow] isNeedsLayout: false
  ScrollBarSkin$3@36602364[styleClass=decrement-button] isNeedsLayout: false
  Region@178b3dbd[styleClass=decrement-arrow] isNeedsLayout: false
  StackPane@d25f45e[styleClass=track] isNeedsLayout: false
  ScrollBarSkin$1@4231e81e[styleClass=thumb] isNeedsLayout: false
  VirtualScrollBar@6662e7e5[styleClass=scroll-bar] isNeedsLayout: false
  StackPane@63f40cf6[styleClass=track-background] isNeedsLayout: false
  ScrollBarSkin$2@36c6ae74[styleClass=increment-button] isNeedsLayout: false
  Region@261de839[styleClass=increment-arrow] isNeedsLayout: false
  ScrollBarSkin$3@7f53c4b5[styleClass=decrement-button] isNeedsLayout: false
  Region@6c2c3d1f[styleClass=decrement-arrow] isNeedsLayout: false
  StackPane@721aca85[styleClass=track] isNeedsLayout: false
  ScrollBarSkin$1@314afb8c[styleClass=thumb] isNeedsLayout: false
  StackPane@5183e9a5[styleClass=corner] isNeedsLayout: false
Run Code Online (Sandbox Code Playgroud)

Sai*_*dem 4

关于文本转变的一项观察:

如果我在显示舞台后将数据添加到treeView,则单元格的布局与在显示舞台前添加数据时的单元格布局方向相反。

案例#1:在显示舞台之前添加数据时的单元格布局:

Layouting ::> Group 1
Layouting ::> Team
Layouting ::> Red
Layouting ::> Green
Layouting ::> Yellow
Layouting ::> Blue
Layouting ::> Group 2
Run Code Online (Sandbox Code Playgroud)

案例#2:显示舞台后添加数据时的单元格布局:

Layouting ::> Group 2
Layouting ::> Blue
Layouting ::> Yellow
Layouting ::> Green
Layouting ::> Red
Layouting ::> Team
Layouting ::> Group 1
Run Code Online (Sandbox Code Playgroud)

我可以看到这是 中的另一个问题TreeView。简单解释一下这个问题:

  • TreeCellSkin内部维护一个静态 map( maxDisclosureWidthMap) 来跟踪TreeView.
  • 假设我自定义了披露节点宽度(通过 css),使其宽度大于 18px(18px 是TreeCellSkin!! 中的硬编码值)。
  • 如果节点从下到上布局(case#2),它将对所有单元使用默认的 18px 公开节点宽度,直到找到第一个箭头(此处为“ Team ”)。
  • 一旦发现宽度大于默认宽度,它就会更新地图。但是没有代码可以重新计算已经计算的单元格!
  • 当下一个布局请求到来时,现在所有单元格都使用更新的公开节点宽度进行计算。这就是文本变化的原因。

为了证明这一点,如果我更新我的 css 以使披露节点具有更大的左填充(例如50px)。在下面的 gif 中,您可以看到“团队”上方的单元格布局正确。当我选择树视图(强制布局请求)时,其他单元格使用正确的公开节点宽度并移动文本。

.virtual-flow .clipped-container .sheet .tree-cell .tree-disclosure-node {
  -fx-padding: 5px 6px 3px 50px; /* default is 4px 6px 4px 8px */
}

.virtual-flow .clipped-container .sheet .tree-cell:expanded > .tree-disclosure-node {
  -fx-padding: 7px 6px 1px 50px; /* default is 4px 6px 4px 8px */
}
Run Code Online (Sandbox Code Playgroud)

在此输入图像描述

我的最终解决方案: [过时] [参见下面的另一个解决方案]

考虑到上述所有问题(不一致的 isNeedsLayout、文本移位等),我想出了以下解决方案。

这个想法是开始不断AnimationTimer检查是否有任何子节点isNeedsLayout为真。如果为 true,则通过调用该layout()方法强制进行布局。在树视图初始化后添加以下代码后,所有问题都得到解决。

// Code to add after initializing TreeView
new AnimationTimer() {
    @Override
    public void handle(final long now) {
        forceLayout(fxTree);
    }
}.start();

private void forceLayout(final Parent parent) {
    for (final Node n : parent.getChildrenUnmodifiable()) {
        if (n instanceof final Parent p) {
            forceLayout(p);
        }
    }
    if (parent.isNeedsLayout()) {
        parent.layout();
    }
}
Run Code Online (Sandbox Code Playgroud)

现在我的下一个最大的担心是:它会降低性能吗?

[更新]替代解决方案

虽然@kleopatra提供的答案解决了这个问题,但打开窗口时我仍然可以看到快速的布局跳转。这是非常明显的行为,因为我们在未来的某个未知时间(Platform.runLater)请求布局。

为了消除这种影响,我需要确保在同一脉冲中纠正布局。因此,每当披露节点宽度发生变化时,我都会在脉冲结束时提出forceLayout的解决方案(我之前的解决方案) (@kleopatra的解决方案

新的解决方案是:

  • 每当公开节点宽度发生变化时,我们都会在场景的 postLayoutPulseListener 中注册一个侦听器以强制布局 treeView 。
  • 在脉冲中的layoutPass之后,这个postLayoutPulseListener将强制对treeView中的所有子级进行布局。
  • 完成力布局后,我们将删除此监听器,以确保它不会在下一个脉冲中触发。

以下是使用新解决方案的示例的完整工作代码(使用相同的treeview.css):

import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.application.Application;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.scene.control.skin.TreeCellSkin;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;
import javafx.util.Duration;

import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class TreeViewLayoutIssue extends Application {
    int k = 1;

    /**
     * Utility class to hack around JDK-8288665: broken layout of
     * nested TreeCells.
     */
    public class DisclosureNodeHack {

        /**
         * Key for max disclosure node width
         */
        public static final String DISCLOSURE_NODE_WIDTH = "disclosureNodeWidth";


        public static class HackedTreeCell<String> extends TreeCell<String> {

            Label lbl = new Label();

            @Override
            protected void updateItem(String item, boolean empty) {
                super.updateItem(item, empty);
                setText(null);
                if (item != null) {
                    lbl.setText((java.lang.String) item);
                    setGraphic(lbl);
                } else {
                    setGraphic(null);
                }
            }

            @Override
            protected void layoutChildren() {
                super.layoutChildren();
                if (getItem() != null) {
                    System.out.println("Laid-out TreeCell ::> " + getItem());
                }
            }

            @Override
            protected Skin<?> createDefaultSkin() {
                return new DisclosureNodeHack.HackedTreeCellSkin<>(this);
            }

            public final HackedTreeView<String> getHackedTreeView() {
                return (HackedTreeView<String>) getTreeView();
            }
        }

        /**
         * Custom skin that puts the width of the disclosure node in the
         * Tree's properties.
         */
        public static class HackedTreeCellSkin<T> extends TreeCellSkin<T> {

            HackedTreeCell<T> cell;

            public HackedTreeCellSkin(HackedTreeCell<T> control) {
                super(control);
                cell = control;
            }

            @Override
            protected void layoutChildren(double x, double y, double w, double h) {
                super.layoutChildren(x, y, w, h);
                if (getSkinnable().getTreeItem() == null || getSkinnable().getTreeView() == null) return;
                Node disclosure = getSkinnable().lookup(".tree-disclosure-node");
                if (disclosure instanceof Region) {
                    double width = ((Region) disclosure).getWidth();
                    Object prevWidth = getSkinnable().getTreeView().getProperties().get(DISCLOSURE_NODE_WIDTH);
                    getSkinnable().getTreeView().getProperties().put(DISCLOSURE_NODE_WIDTH, width);
                    if (prevWidth == null || ((Double) prevWidth).doubleValue() != width) {
                        cell.getHackedTreeView().installListener();
                    }
                }
            }
        }

        public static class HackedTreeView<T> extends TreeView<T> {
            private Runnable listener = new Runnable() {
                @Override
                public void run() {
                    System.out.println("------ Forcing Layout ------");
                    forceLayout(HackedTreeView.this);
                    getScene().removePostLayoutPulseListener(this);
                }
            };

            public HackedTreeView() {
                setCellFactory(t -> new DisclosureNodeHack.HackedTreeCell());
            }

            private void forceLayout(final Parent parent) {
                for (final Node n : parent.getChildrenUnmodifiable()) {
                    if (n instanceof final Parent p) {
                        forceLayout(p);
                    }
                }
                if (parent.isNeedsLayout()) {
                    parent.layout();
                }
            }

            public final void installListener() {
                getScene().removePostLayoutPulseListener(listener);
                getScene().addPostLayoutPulseListener(listener);
            }
        }

        private DisclosureNodeHack() {
        }
    }

    @Override
    public void start(Stage primaryStage) throws Exception {
        final DisclosureNodeHack.HackedTreeView<String> fxTree = new DisclosureNodeHack.HackedTreeView<>();
        fxTree.setShowRoot(false);

        StackPane root = new StackPane(fxTree);
        root.setPadding(new Insets(15));
        final Scene scene = new Scene(root, 250, 250);
        scene.getStylesheets().add(this.getClass().getResource("treeview.css").toExternalForm());
        primaryStage.setTitle("TreeView FX18");
        primaryStage.setScene(scene);
        primaryStage.show();
        addData(fxTree);

        final Timeline timeline = new Timeline(new KeyFrame(Duration.millis(2000), e -> {
            System.out.println("\nIteration #" + k++);
            printNeedsLayout(fxTree);
            System.out.println("-----------------------------------------------------------------------------");
        }));
        timeline.setCycleCount(1);
        timeline.play();
    }

    private void printNeedsLayout(final Parent parent) {
        System.out.println("  " + parent + " isNeedsLayout: " + parent.isNeedsLayout());
        for (final Node n : parent.getChildrenUnmodifiable()) {
            if (n instanceof Parent) {
                printNeedsLayout((Parent) n);
            }
        }
    }

    private void addData(TreeView<String> fxTree) {
        final TreeItem<String> rootNode = new TreeItem<>("");
        fxTree.setRoot(rootNode);
        final TreeItem<String> grp1Node = new TreeItem<>("Group 1");
        final TreeItem<String> grp2Node = new TreeItem<>("Group 2");
        rootNode.getChildren().addAll(grp1Node, grp2Node);

        final TreeItem<String> subNode = new TreeItem<>("Team");
        grp1Node.getChildren().addAll(subNode);

        final List<TreeItem<String>> groups = Stream.of("Red", "Green", "Yellow", "Blue").map(TreeItem::new).collect(Collectors.toList());
        groups.forEach(itm -> subNode.getChildren().add(itm));

        grp1Node.setExpanded(true);
        grp2Node.setExpanded(true);
        subNode.setExpanded(true);
    }
}
Run Code Online (Sandbox Code Playgroud)

  • 很好的跟踪:) 真的是时候提交错误报告了...*咳,今天晚些时候会做。至于AnimationTimer:将其放入生产代码中会非常疲倦 (2认同)