javafx TableView 与行上的动态上下文菜单

javafx TableView with dynamic ContextMenu on rows

我正在尝试使用 DLNA 控制点制作 java 媒体播放器。

有 table 媒体文件。
使用 JavaFX TableView,我了解到,在 setRowFactory 回调中,我们可以在由 table 元素属性生成的事件上添加监听器。 TableView 的所有事件类型仅在内部 table 数据更改时触发。 在某些外部事件或逻辑的情况下,我无法找到进入 table 行的方法,也无法修改例如每行的 ContextMenu。

table 中的每一行代表一个媒体文件。 ContextMenu 最初只有 "Play"(本地)和 "Delete" 菜单项。 例如,网络上出现了 DLNA 渲染器设备。 DLNA 发现线程触发了一个事件,我想在每个 table 行的上下文菜单中添加一个 "Play to this device" 菜单项。相应地,我将需要删除此项目,相应的设备将关闭。

如何从 rowFactory 外部挂钩到每一行的上下文菜单?

这是 table 和行工厂的代码

    public FileManager(GuiController guiController) {

        gCtrl = guiController;
        gCtrl.fileName.setCellValueFactory(new PropertyValueFactory<FileTableItem, String>("Name"));
        gCtrl.fileType.setCellValueFactory(new PropertyValueFactory<FileTableItem, String>("Type"));
        gCtrl.fileSize.setCellValueFactory(new PropertyValueFactory<FileTableItem, String>("Size"));
        gCtrl.fileTime.setCellValueFactory(new PropertyValueFactory<FileTableItem, String>("modifiedTime"));


        gCtrl.filesTable.setRowFactory(tv -> {
            TableRow<FileTableItem> row = new TableRow<>();
            row.emptyProperty().addListener((obs, wasEmpty, isEmpty) -> {
                if (!isEmpty) {
                    FileTableItem file = row.getItem();
                    ContextMenu contextMenu = new ContextMenu();

                    if (file.isPlayable()) {
                        row.setOnMouseClicked(event -> {
                            if (event.getClickCount() == 2) {
                                gCtrl.playMedia(file.getAbsolutePath());
                            }
                        });

                        MenuItem playMenuItem = new MenuItem("Play");
                        playMenuItem.setOnAction(event -> {
                            gCtrl.playMedia(file.getAbsolutePath());
                        });
                        contextMenu.getItems().add(playMenuItem);
                    }

                    if (file.canWrite()) {
                        MenuItem deleteMenuItem = new MenuItem("Delete");
                        deleteMenuItem.setOnAction(event -> {
                            row.getItem().delete();
                        });
                        contextMenu.getItems().add(deleteMenuItem);
                    }
                    row.setContextMenu(contextMenu);
                }
            });
            return row;
        });
        gCtrl.filesTable.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
    }
    ...
    public class FileTableItem extends File {
    ...
    }

提前致谢

JavaFX 通常遵循 MVC/MVP 类型模式。在 table 视图中,TableRow 是视图的一部分:因此要更改 table 行的外观(在这种情况下包括与其关联的上下文菜单的内容),您应该让它观察某种模型,并且要更改上下文菜单中显示的内容,您需要更改该模型。

我不完全确定我是否正确理解了您的用例,但我想我理解 table 中的每个项目可能有一组不同的设备与之关联。所以你会有一个实体 class 看起来像这样:

public class FileTableItem extends File {

    private final ObservableList<Device> devices = FXCollections.observableArrayList();

    public ObservableList<Device> getDevices() {
        return devices ;
    }
}

当您创建table行时,您需要它来观察与其当前项目关联的设备列表;你可以用 ListChangeListener 来做到这一点。当然,一行在任何给定时间显示的项目可能会在您无法控制的任意时间发生变化(例如,当用户滚动 table 时),因此您需要观察该行的项目 属性 并确保 ListChangeListener 正在观察正确的项目列表。这是实现此目的的一些代码:

TableView<FileTableItem> filesTable = new TableView<>();
filesTable.setRowFactory(tv -> {
    TableRow<FileTableItem> row = new TableRow<>();
    ContextMenu menu = new ContextMenu();
    ListChangeListener<FileTableItem> changeListener = (ListChangeListener.Change<? extends FileTableItem> c) -> 
        updateMenu(menu, row.getItem().getDevices());

    row.itemProperty().addListener((obs, oldItem, newItem) -> {
        if (oldItem != null) {
            oldItem.getDevices().removeListener(changeListener);
        }
        if (newItem == null) {
            contextMenu.getItems().clear();
        } else {
            newItem.getDevices().addListener(changeListener);
            updateMenu(menu, newItem.getDevices());
        }
    });

    row.emptyProperty().addListener((obs, wasEmpty, isNowEmpty) -> 
         row.setContextMenu(isNowEmpty ? null : menu));

    return row ;
});

// ...

private void updateMenu(ContextMenu menu, List<Device> devices) {
    menu.getItems().clear();
    for (Device device : devices) {
        MenuItem item = new MenuItem(device.toString());
        item.setOnAction(e -> { /* ... */ });
        menu.getItems().add(item);
    }

}

如果设备列表发生变化,这将自动更新上下文菜单。

在你的问题下方的评论中,你说你希望 table 中有一个 getRows() 方法。没有这样的方法,部分原因是设计使用了所描述的 MVC 方法。即使有,也无济于事:假设滚动到视图之外的项目的设备列表已更改 - 在这种情况下,不会有与该项目对应的 TableRow,因此您将无法获取对行的引用以更改其上下文菜单。相反,使用所描述的设置,您只需在代码中您打算更新 table 行的位置更新模型。

如果您有不依赖于列表等的菜单项,您可能需要修改它,但这应该足以说明这个想法。

这是一个 SSCCE。在此示例中,table 中最初有 20 个项目,没有附加任何设备。每个的上下文菜单仅显示一个 "Delete" 选项,用于删除该项目。我没有使用更新项目的后台任务,而是使用一些控件来模拟它。您可以 select table 中的项目并通过按 "Add device" 按钮向其添加设备:随后您将看到 "Play on device...." 出现在其上下文菜单中。同样,"Remove device" 将删除列表中的最后一个设备。 "Delay" 复选框会将设备的添加或删除延迟两秒:这允许您按下按钮,然后(快速)打开上下文菜单;您会在显示时看到上下文菜单更新。

import javafx.animation.PauseTransition;
import javafx.application.Application;
import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.CheckBox;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.MenuItem;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableRow;
import javafx.scene.control.TableView;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.stage.Stage;
import javafx.util.Duration;

public class DynamicContextMenuInTable extends Application {

    private int deviceCount = 0 ;

    private void addDeviceToItem(Item item) {
        Device newDevice = new Device("Device "+(++deviceCount));
        item.getDevices().add(newDevice);
    }

    private void removeDeviceFromItem(Item item) {
        if (! item.getDevices().isEmpty()) {
            item.getDevices().remove(item.getDevices().size() - 1);
        }
    }

    @Override
    public void start(Stage primaryStage) {
        TableView<Item> table = new TableView<>();
        TableColumn<Item, String> itemCol = new TableColumn<>("Item");
        itemCol.setCellValueFactory(cellData -> new SimpleStringProperty(cellData.getValue().getName()));
        table.getColumns().add(itemCol);

        table.setRowFactory(tv -> {
            TableRow<Item> row = new TableRow<>();
            ContextMenu menu = new ContextMenu();

            MenuItem delete = new MenuItem("Delete");
            delete.setOnAction(e -> table.getItems().remove(row.getItem()));

            menu.getItems().add(delete);

            ListChangeListener<Device> deviceListListener = c -> 
                updateContextMenu(row.getItem(), menu);

            row.itemProperty().addListener((obs, oldItem, newItem) -> {
                if (oldItem != null) {
                    oldItem.getDevices().removeListener(deviceListListener);
                }
                if (newItem != null) {
                    newItem.getDevices().addListener(deviceListListener);
                    updateContextMenu(row.getItem(), menu);
                }
            });

            row.emptyProperty().addListener((obs, wasEmpty, isNowEmpty) -> 
                row.setContextMenu(isNowEmpty ? null : menu));

            return row ;
        });

        CheckBox delay = new CheckBox("Delay");

        Button addDeviceButton = new Button("Add device");
        addDeviceButton.disableProperty().bind(table.getSelectionModel().selectedItemProperty().isNull());
        addDeviceButton.setOnAction(e -> {
            Item selectedItem = table.getSelectionModel().getSelectedItem();
            if (delay.isSelected()) {
                PauseTransition pause = new PauseTransition(Duration.seconds(2));
                pause.setOnFinished(evt -> {
                    addDeviceToItem(selectedItem);
                });
                pause.play();
            } else {
                addDeviceToItem(selectedItem);
            }
        });

        Button removeDeviceButton = new Button("Remove device");
        removeDeviceButton.disableProperty().bind(table.getSelectionModel().selectedItemProperty().isNull());
        removeDeviceButton.setOnAction(e -> {
            Item selectedItem = table.getSelectionModel().getSelectedItem() ;
            if (delay.isSelected()) {
                PauseTransition pause = new PauseTransition(Duration.seconds(2));
                pause.setOnFinished(evt -> removeDeviceFromItem(selectedItem));
                pause.play();
            } else {
                removeDeviceFromItem(selectedItem);
            }
        });

        HBox buttons = new HBox(5, addDeviceButton, removeDeviceButton, delay);
        BorderPane.setMargin(buttons, new Insets(5));
        BorderPane root = new BorderPane(table, buttons, null, null, null);

        for (int i = 1 ; i <= 20; i++) {
            table.getItems().add(new Item("Item "+i));
        }

        Scene scene = new Scene(root);
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    private void updateContextMenu(Item item, ContextMenu menu) {
        if (menu.getItems().size() > 1) {
            menu.getItems().subList(1, menu.getItems().size()).clear();
        }
        for (Device device : item.getDevices()) {
            MenuItem menuItem = new MenuItem("Play on "+device.getName());
            menuItem.setOnAction(e -> System.out.println("Play "+item.getName()+" on "+device.getName()));
            menu.getItems().add(menuItem);
        }
    }

    public static class Device {
        private final String name ;

        public Device(String name) {
            this.name = name ;
        }

        public String getName() {
            return name ;
        }

        @Override
        public String toString() {
            return getName();
        }
    }

    public static class Item {
        private final ObservableList<Device> devices = FXCollections.observableArrayList() ;

        private final String name ;

        public Item(String name) {
            this.name = name ;
        }

        public ObservableList<Device> getDevices() {
            return devices ;
        }

        public String getName() {
            return name ;
        }
    }

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

根据 sillyfly 的建议,我得到了可行的解决方案,但它可能存在性能缺陷。所以找到一个更好的会很有趣。

class FileManager {


    private GuiController gCtrl;

    protected Menu playToSub = new Menu("Play to...");
    Map<String, MenuItem> playToItems = new HashMap<String, MenuItem>();

    public FileManager(GuiController guiController) {

        gCtrl = guiController;

        gCtrl.fileName.setCellValueFactory(new PropertyValueFactory<FileTableItem, String>("Name"));
        gCtrl.fileType.setCellValueFactory(new PropertyValueFactory<FileTableItem, String>("Type"));
        gCtrl.fileSize.setCellValueFactory(new PropertyValueFactory<FileTableItem, String>("Size"));
        gCtrl.fileTime.setCellValueFactory(new PropertyValueFactory<FileTableItem, String>("modifiedTime"));

        gCtrl.filesTable.setRowFactory(tv -> {
            TableRow<FileTableItem> row = new TableRow<>();
            row.emptyProperty().addListener((obs, wasEmpty, isEmpty) -> {
                if (!isEmpty) {
                    FileTableItem file = row.getItem();
                    ContextMenu contextMenu = new ContextMenu();

                    if (file.isPlayable()) {
                        row.setOnMouseClicked(event -> {
                            if (event.getClickCount() == 2) {
                                gCtrl.mainApp.playFile = file.getName();
                                gCtrl.playMedia(file.getAbsolutePath());
                            }
                        });

                        MenuItem playMenuItem = new MenuItem("Play");
                        playMenuItem.setOnAction(event -> {
                            gCtrl.mainApp.playFile = file.getName();
                            gCtrl.playMedia(file.getAbsolutePath());
                        });
                        contextMenu.getItems().add(playMenuItem);
                    }

                    if (file.canWrite()) {
                        MenuItem deleteMenuItem = new MenuItem("Delete");
                        deleteMenuItem.setOnAction(event -> {
                            row.getItem().delete();
                        });
                        contextMenu.getItems().add(deleteMenuItem);
                    }
                    row.setContextMenu(contextMenu);
                }
            });
            row.setOnContextMenuRequested((event) -> {

                /// Here, just before showing the context menu we can decide what to show in it
                /// In this particular case it's OK, but it may be time expensive in general
                if(! row.isEmpty()) {
                    if(gCtrl.mainApp.playDevices.size() > 0) {
                        if(! row.getContextMenu().getItems().contains(playToSub)) {
                            row.getContextMenu().getItems().add(1, playToSub);
                        }
                    }
                    else {
                        if(row.getContextMenu().getItems().contains(playToSub)) {
                            row.getContextMenu().getItems().remove(playToSub);
                        }
                    }
                }
            });
            return row;
        });
        gCtrl.filesTable.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
    }

    /// addPlayToMenuItem and removePlayToMenuItem are run from Gui Controller
    /// which in turn is notified by events in UPNP module
    /// The playTo sub menu items are managed here
    public void addPlayToMenuItem(String uuid, String name, URL iconUrl) {
        MenuItem playToItem = new PlayToMenuItem(uuid, name, iconUrl);
        playToItems.put(uuid, playToItem);
        playToSub.getItems().add(playToItem);
    }

    public void removePlayToMenuItem(String uuid) {
        if(playToItems.containsKey(uuid)) {
            playToSub.getItems().remove(playToItems.get(uuid));
            playToItems.remove(uuid);
        }
    }

    public class PlayToMenuItem extends MenuItem {
        PlayToMenuItem(String uuid, String name, URL iconUrl) {
            super();
            if (iconUrl != null) {
                Image icon = new Image(iconUrl.toString());
                ImageView imgView = new ImageView(icon);
                imgView.setFitWidth(12);
                imgView.setPreserveRatio(true);
                imgView.setSmooth(true);
                imgView.setCache(true);
                setGraphic(imgView);
            }
            setText(name);
            setOnAction(event -> {
                gCtrl.mainApp.playFile =    gCtrl.filesTable.getSelectionModel().getSelectedItem().getName();
                gCtrl.mainApp.startRemotePlay(uuid);
            });
        }
    }

    /// Other class methods and members

}