是否可以重新加载相同的 FXML/Controller 实例?

Is it possible to reload the same FXML/Controller instance?

目标: 实施标准 "Settings" GUI window。左侧 ListView 中的类别和右侧 Pane 中的相应选项。 (请忽略重复类别的明显错误;仍在处理中)

我有一个主要 window 用于整体设置 window,其中包含一个 ListView 和所有设置类别。 window 的右侧有一个 AnchorPane,用于在从列表中选择一个类别时为每个类别加载单独的 FXML 文件。

当用户选择一个类别时,我需要他们能够在右侧编辑设置,切换到另一个类别并进行更多更改。然而,如果他们 return 到第一类,则在那里所做的更改仍然存在。

我的明显问题是,每次用户更改类别时,FXMLLoader 都会重新加载 FXML 文件和控制器,将其中的所有控件重置为其默认值。

那么是否可以重复使用已经加载和更改的 FXML 文件?

研究:

我发现似乎可以解决该问题的唯一答案是 How to swich javafx application controller without reloading FXML file?。这提到为 FXML 控制器使用 Singleton,但没有解决每次重新加载 FXML 文件本身的问题。

如果有人能指出此类“设置”菜单的基本示例,我会很高兴。

好的,所以我做了一些测试,我可能想出了一个方法来做到这一点。

先说明一下我的思路,再上代码。

看起来您基本上想要 TabPane,没有 Tab。即点击ListView,切换到某个FXML文件。好吧,我做了一个小实验,看看我能做什么。

首先,我在左边用了一个SplitPaneListView,还有两个BorderPanes。在嵌套的 BorderPane 中,我放了一个 TabPane,这是您将添加 fxml 文件的地方。我用 fx:include,来保存 time/code。另外,由于这将是标准的,添加或删除 Settings 项目只需几行 added/removed.

因此,当您select ListView的项目时,它将其更改为适当的选项卡,其中FXML文件的文件与其具有相同的index select离子(有一个警告)。可以对其进行编辑以满足您的需求,但由于这是概念验证,因此我不会深入研究。

应该 允许您在点击保存之前保留 "soft saves" 的用户更改 Button

代码如下:

Main.java

public class Main extends Application {
    public static void main(String[] args) {
        launch(args);
    }
    @Override
    public void start(Stage primaryStage)throws Exception{
        FXMLLoader loader = new FXMLLoader(getClass().getResource("root.fxml"));

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

MainController.java

public class MainController {
    @FXML
    private ListView listView;
    @FXML
    private TabPane tabPane;

    public void initialize() {
        ObservableList<String> list = FXCollections.observableArrayList();
        list.add("Settings 1");
        list.add("Settings 2");
        list.add("Settings 3");
        list.add("Settings 4");
        listView.setItems(list);

        listView.getSelectionModel().selectedItemProperty().addListener(listener -> {
            tabPane.getSelectionModel().select(listView.getSelectionModel().getSelectedIndex());    
        });
    }
}

root.fxml

<BorderPane maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" prefHeight="400.0" prefWidth="600.0" stylesheets="@root.css" xmlns="http://javafx.com/javafx/8.0.91" xmlns:fx="http://javafx.com/fxml/1" fx:controller="sample.MainController">
   <left>
      <ListView fx:id="listView" prefHeight="200.0" prefWidth="200.0" BorderPane.alignment="CENTER" />
   </left>
   <center>
      <BorderPane prefHeight="200.0" prefWidth="200.0" BorderPane.alignment="CENTER">
         <bottom>
            <HBox prefHeight="100.0" prefWidth="200.0" BorderPane.alignment="CENTER">
               <children>
                  <Button mnemonicParsing="false" text="Cancel" />
                  <Button mnemonicParsing="false" text="Save Changes" />
               </children>
            </HBox>
         </bottom>
         <center>
            <TabPane fx:id="tabPane" prefHeight="200.0" prefWidth="200.0" tabClosingPolicy="UNAVAILABLE" BorderPane.alignment="CENTER">
              <tabs>
                <Tab>
                     <content>
                        <fx:include source="settings1.fxml" />
                     </content>
                </Tab>
                  <Tab>
                      <content>
                          <fx:include source="settings2.fxml" />
                      </content>
                  </Tab>
                  <Tab>
                      <content>
                          <fx:include source="settings3.fxml" />
                      </content>
                  </Tab>
                  <Tab>
                      <content>
                          <fx:include source="settings4.fxml" />
                      </content>
                  </Tab>
              </tabs>
            </TabPane>
         </center>
      </BorderPane>
   </center>
</BorderPane>

settings1.fxml, settings2.fxml, settings3.fxml, settings4.fxml. Only thing different is the Label is changed to reflect the FXML file.

<AnchorPane maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" prefHeight="400.0" prefWidth="600.0" xmlns="http://javafx.com/javafx/8.0.91" xmlns:fx="http://javafx.com/fxml/1">
   <children>
      <Label layoutX="168.0" layoutY="14.0" text="Settings 1" />
      <TextField layoutX="127.0" layoutY="39.0" />
   </children>
</AnchorPane>

root.css. Located

.tab-pane {
    -fx-tab-max-height: 0 ;
} 
.tab-pane .tab-header-area {
    visibility: hidden ;
}

我看到基本上有三种方法可以做到这一点:

  1. 定义表示数据的模型 (Settings),并创建它的单个实例。每次重新加载 FXML 文件,并将单个实例传递给控制器​​。将 UI 中的数据与模型中的数据绑定。这样,当您重新加载 FXML 时,它将使用相同的数据进行更新。 (这是我的首选。)
  2. 创建控制器一次。每次重新加载 FXML 文件,每次都设置相同的控制器。让 initialize() 方法从本地存储的字段或模型更新 UI。当您重新加载 FXML 文件时,@FXML 注释的字段将被替换,并且将调用 initialize() 方法,用现有数据更新新控件。 (这感觉有点做作。从道德上讲,任何名为 initialize() 的方法都应该只执行一次。但是,这是完全可行的。)
  3. 加载每个 FXML 文件一次并缓存 UI(可能还有控制器)。然后当用户在列表视图中选择某些东西时,只显示已经加载的视图。这可能是最简单的,但由于您始终将所有视图保存在内存中,因此会占用更多内存。

假设您有一个模型,它可能看起来像这样:

public class Settings {

    private final UserInfo userInfo ;
    private final Preferences prefs ;
    private final Appearance appearance ;

    public Settings(UserInfo userInfo, Preferences prefs, Appearance appearance) {
        this.userInfo = userInfo ;
        this.prefs = prefs ;
        this.appearance = appearance ;
    }

    public Settings() {
        this(new UserInfo(), new Preferences(), new Appearance());
    }

    public UserInfo getUserInfo() {
        return userInfo ;
    }

    public Preferences getPreferences() {
        return prefs ;
    }

    public Appearance getAppearance() {
       return appearance ;
    }
}

public class UserInfo {

    private final StringProperty name = new SimpleStringProperty() ;
    private final StringProperty department = new SimpleStringProperty() ;
    // etc...

    public StringProperty nameProperty() {
        return name ;
    }

    public final String getName() {
        return nameProperty().get();
    }

    public final void setName(String name) {
        nameProperty().set(name);
    }

    // etc...
}

PreferencesAppearance 等也类似)

现在您可以为使用模型的各个屏幕定义控制器,例如

public class UserInfoController {

    private final UserInfo userInfo ;

    @FXML
    private TextField name ;
    @FXML
    private ComboBox<String> department ;

    public UserInfoController(UserInfo userInfo) {
        this.userInfo = userInfo ;
    }

    public void initialize() {
        name.textProperty().bindBidirectional(userInfo.nameProperty());
        department.valueProperty().bindBidirectional(userInfo.departmentProperty());
    }
}

然后你的主控制器看起来像:

public class MainController {

    @FXML
    private BorderPane root ;
    @FXML
    private ListView<String> selector ;

    private Settings settings = new Settings() ; // or pass in from somewhere else..

    public void initialize() {
        selector.getSelectionModel().selectedItemProperty().addListener((obs, oldSelection, newSelection) -> {
            if ("User Information".equals(newSelection)) {
                loadScreen("UserInfo.fxml", new UserInfoController(settings.getUserInfo()));
            } else if ("Preferences".equals(newSelection)) {
                loadScreen("Preferences.fxml", new PreferencesController(settings.getPreferences()));
            } else if ("Appearance".equals(newSelection)) {
                loadScreen("Appearance.fxml", new AppearanceController(settings.getAppearance()));
            } else {
                root.setCenter(null);
            }
    }

    private void loadScreen(String resource, Object controller) {
        try {
            FXMLLoader loader = new FXMLLoader(getClass().getResource(resource));
            loader.setController(controller);
            root.setCenter(loader.load());
        } catch (IOException exc) {
            exc.printStackTrace();
            root.setCenter(null);
        }
    }
}

(显然,您可以通过定义一个简单的视图 class 封装资源名称、显示名称和控制器的工厂,并用它填充列表视图,从而使列表视图的处理程序更清晰,而不是打开字符串。)

请注意,由于您在代码中将控制器设置在 FXMLLoader 上,因此 UserInfo.fxmlPreferences.fxmlAppearance.fxml 应该 而不是 定义了 fx:controller 属性。


第二种选择只是对此进行温和的重构。创建控制器一次并保留对它们的引用。请注意,如果你愿意,你可以在这个版本中去掉模型,因为控制器有数据,所以你可以只引用它们。所以这可能看起来像

public class UserInfoController {

    @FXML
    private TextField name ;
    @FXML
    private ComboBox<String> department ;

    private final StringProperty nameProp = new SimpleStringProperty();
    private final ObjectProperty<String> departmentProp = new SimpleObjectProperty();

    public StringProperty nameProperty() {
        return nameProp;
    }

    public final String getName() {
        return nameProperty().get();
    }

    public final void setName(String name) {
        nameProperty().set(name);
    }

    public ObjectProperty<String> departmentProperty() {
        return departmentProp ;
    }

    public final String getDepartment() {
        return departmentProperty().get();
    }

    public final void setDepartment(String department) {
        departmentProperty().set(department);
    }

    public void initialize() {
        // initialize controls with data currently in properties, 
        // and ensure changes to controls are written back to properties:
        name.textProperty().bindBidirectional(nameProp);
        department.valueProperty().bindBidirectional(departmentProp);
    }
}

然后

public class MainController {

    @FXML
    private BorderPane root ;
    @FXML
    private ListView<String> selector ;

    private UserInfoController userInfoController = new UserInfoController();
    private PreferencesController preferencesController = new PreferencesController();
    private AppearanceController appearanceController = new AppearanceController();

    public void initialize() {
        // initialize controllers with data if necessary...

        selector.getSelectionModel().selectedItemProperty().addListener((obs, oldSelection, newSelection) -> {
        selector.getSelectionModel().selectedItemProperty().addListener((obs, oldSelection, newSelection) -> {
            if ("User Information".equals(newSelection)) {
                loadScreen("UserInfo.fxml", userInfoController);
            } else if ("Preferences".equals(newSelection)) {
                loadScreen("Preferences.fxml", preferencesController);
            } else if ("Appearance".equals(newSelection)) {
                loadScreen("Appearance.fxml", appearanceController);
            } else {
                root.setCenter(null);
            }
        }
    }

    private void loadScreen(String resource, Object controller) {
        // as before...
    }
}

之所以可行,是因为您在重新加载 FXML 文件时没有创建新的控制器,并且控制器中的初始化方法使用已有的数据更新控件。 (注意调用 bindBidirectional 方法的方式。)


第三个选项可以在主控制器中实现,也可以在主 fxml 文件中实现。要在控制器中实现它,你基本上做

public class MainController {

    @FXML
    private BorderPane root ;
    @FXML
    private ListView<String> selector ;

    private Parent userInfo ;
    private Parent prefs;
    private Parent appearance;

    // need controllers to get data later...

    private UserInfoController userInfoController ;
    private PreferencesController prefsController ;
    private AppearanceController appearanceController ;

    public void initialize() throws IOException {

        FXMLLoader userInfoLoader = new FXMLLoader(getClass().getResource("userInfo.fxml));
        userInfo = userInfoLoader.load();
        userInfoController = userInfoLoader.getController();

        FXMLLoader prefsLoader = new FXMLLoader(getClass().getResource("preferences.fxml));
        prefs = prefsLoader.load();
        prefsController = prefsLoader.getController();

        FXMLLoader appearanceLoader = new FXMLLoader(getClass().getResource("appearance.fxml));
        appearance = appearanceLoader.load();
        appearanceController = appearanceLoader.getController();

        // configure controllers with data if needed...

        selector.getSelectionModel().selectedItemProperty().addListener((obs, oldSelection, newSelection) -> {
            if ("User Information".equals(newSelection)) {
                root.setCenter(userInfo);
            } else if ("Preferences".equals(newSelection)) {
                root.setCenter(prefs); 
            } else if ("Appearance".equals(newSelection)) {
                root.setCenter(prefs);
            } else {
                root.setCenter(null);
            }
        }
    }
}

请注意,您将在 FXML 文件中恢复为常用的 fx:controller 属性。

这将起作用,因为您只加载 FXML 文件一次,因此视图会保持其所有状态。

如果您想用这种方法定义 FXML 中的视图,您可以:

主 fxml 文件:

<!-- imports etc omitted -->
<BorderPane xmlns="http://javafx.com/javafx/8.0.111" xmlns:fx="http://javafx.com/fxml/1"
    fx:controller="com.example.MainController">

    <left>
        <ListView fx:id="selector" />
    </left>

    <fx:define>
        <fx:include fx:id="userInfo" source="UserInfo.fxml" >
    </fx:define>

    <fx:define>
        <fx:include fx:id="prefs" source="Preferences.fxml" >
    </fx:define>

    <fx:define>
        <fx:include fx:id="appearance" source="Appearance.fxml" >
    </fx:define>

</BorderPane>

<fx:include> 的 FXML 注入规则是,包含的 FMXL 文件的根目录被注入指定的 fx:id(例如 userInfo)和包含的控制器当 "Controller" 附加到 fx:id 时(例如 userInfoController),文件 ("nested controllers") 被注入到名称为 give 的字段中。所以这个的主控制器现在看起来像

public class MainController {

    @FXML
    private BorderPane root ;
    @FXML
    private ListView<String> selector ;

    @FXML
    private Parent userInfo ;
    @FXML
    private Parent prefs;
    @FXML
    private Parent appearance;

    // need controllers to get data later...

    @FXML
    private UserInfoController userInfoController ;
    @FXML
    private PreferencesController prefsController ;
    @FXML
    private AppearanceController appearanceController ;

    public void initialize() {

        // configure controllers with data if needed...

        selector.getSelectionModel().selectedItemProperty().addListener((obs, oldSelection, newSelection) -> {
            if ("User Information".equals(newSelection)) {
                root.setCenter(userInfo);
            } else if ("Preferences".equals(newSelection)) {
                root.setCenter(prefs); 
            } else if ("Appearance".equals(newSelection)) {
                root.setCenter(prefs);
            } else {
                root.setCenter(null);
            }
        }
    }
}

这是一种完全不同的方法来创建 "navigation pane" 就像您展示的那样,部分灵感来自 Hypnic Jerk 的回答。这里的关键观察是您想要的功能与 TabPane 基本相同:您有一系列节点,一次显示一个,具有选择显示哪个的机制(通常是选项卡,但在这里你有一个 ListView)。因此,这种方法只是使选项卡窗格使用 ListView 而不是通常的选项卡来显示 "selector"。它通过为选项卡窗格创建一个新的 Skin 来实现。

这是基本应用程序:

<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.scene.layout.BorderPane?>
<?import javafx.scene.control.TabPane?>
<?import javafx.scene.control.Tab?>

<BorderPane xmlns:fx="http://javafx.com/fxml/1" fx:controller="application.MainController">
    <center>
        <TabPane  fx:id="navigationPane" id="navigationPane">
            <tabs>
                <Tab text="User Information">
                    <content>
                        <fx:include fx:id="userInfo" source="UserInfo.fxml"/>
                    </content>
                </Tab>
                <Tab text="Preferences">
                    <content>
                        <fx:include fx:id="prefs" source="Preferences.fxml"/>
                    </content>
                </Tab>
                <Tab text="Appearance">
                    <content>
                        <fx:include fx:id="appearance" source="Appearance.fxml"/>
                    </content>
                </Tab>
            </tabs>
        </TabPane>
    </center>
</BorderPane>

此测试的控制器不执行任何操作:

package application;

import javafx.fxml.FXML;
import javafx.scene.control.TabPane;

public class MainController {

    @FXML
    private TabPane navigationPane ;

    public void initialize() {

    }
}

各个窗格只是占位符:

<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.scene.layout.VBox?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.TextField?>

<VBox xmlns:fx="http://javafx.com/fxml/1" minWidth="600" minHeight="400" alignment="CENTER">
    <Label text="User Info Pane"/>
    <TextField  />
</VBox>

应用程序 class 只是加载 FXML,而且至关重要的是,设置样式 sheet:

package application;

import java.io.IOException;

import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Stage;

public class NavTabPaneTest extends Application {

    @Override
    public void start(Stage primaryStage) throws IOException {

        Parent root = FXMLLoader.load(getClass().getResource("NavPaneTest.fxml"));

        Scene scene = new Scene(root);
        scene.getStylesheets().add(getClass().getResource("style.css").toExternalForm());
        primaryStage.setScene(scene);
        primaryStage.show();
    }

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

样式sheet指定皮肤:

#navigationPane {
    -fx-skin: "application.skin.NavigationSkin" ;
}

最后是完成工作的部分:皮肤:

package application.skin;

import java.util.function.ToDoubleFunction;

import javafx.beans.binding.Bindings;
import javafx.collections.ListChangeListener.Change;
import javafx.scene.Node;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.scene.control.SkinBase;
import javafx.scene.control.Tab;
import javafx.scene.control.TabPane;

public class NavigationSkin extends SkinBase<TabPane> {


    private final ListView<Tab> navigator ;

    public NavigationSkin(TabPane control) {
        super(control);

        navigator = new ListView<Tab>();

        navigator.setCellFactory(lv -> {
            ListCell<Tab> cell = new ListCell<>();
            cell.itemProperty().addListener((obs, oldTab, newTab) -> {
                cell.textProperty().unbind();
                cell.graphicProperty().unbind();
                if (newTab == null) {
                    cell.setText(null);
                    cell.setGraphic(null);
                } else {
                    cell.textProperty().bind(newTab.textProperty());
                    cell.graphicProperty().bind(newTab.graphicProperty());
                }
            });
            return cell ;
        });

        navigator.setItems(control.getTabs());  

        navigator.getSelectionModel().selectedItemProperty().addListener(
                (obs, oldItem, newItem) -> control.getSelectionModel().select(newItem));

        navigator.getSelectionModel().select(control.getSelectionModel().getSelectedItem());

        control.getSelectionModel().selectedItemProperty().addListener((obs, oldItem, newItem) -> {
            for (Tab t : control.getTabs()) {
                t.getContent().setVisible(t == control.getSelectionModel().getSelectedItem());
            }
            navigator.getSelectionModel().select(newItem);
        });

        getChildren().add(navigator);
        for (Tab t : control.getTabs()) {
            getChildren().add(t.getContent());
            t.getContent().setVisible(t == control.getSelectionModel().getSelectedItem());
        }


        control.getTabs().addListener((Change<? extends Tab> c) -> {
            while (c.next()) {
                if (c.wasRemoved()) {
                    getChildren().subList(c.getFrom()+1, c.getFrom()+c.getRemovedSize()+1).clear();
                }
                if (c.wasAdded()) {
                    for (int i = 0 ; i < c.getAddedSize() ; i++) {
                        getChildren().add(c.getFrom() + i + 1, c.getAddedSubList().get(i).getContent());
                    }
                }
            }
            getSkinnable().requestLayout();
        });
    }


    @Override
    protected void layoutChildren(double contentX, double contentY, double contentWidth, double contentHeight) {
        double navPrefWidth = navigator.prefWidth(-1);
        navigator.resizeRelocate(contentX, contentY, navPrefWidth, contentHeight);
        for (Tab t : getSkinnable().getTabs()) {
            t.getContent().resizeRelocate(navPrefWidth, 0, contentWidth - navPrefWidth, contentHeight);
        }
    }

    @Override
    protected double computeMaxHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) {
        return computeHeight(n -> n.maxHeight(width - leftInset - rightInset));
    }

    @Override
    protected double computeMaxWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
        return computeWidth(n -> n.maxWidth(height - topInset - bottomInset)) ;
    }

    @Override
    protected double computeMinHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) {
        return computeHeight(n -> n.minHeight(-1));
    }

    @Override
    protected double computeMinWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
        return computeWidth(n -> n.minWidth(-1)) ;
    }   

    @Override
    protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) {
        return computeHeight(n -> n.prefHeight(-1));
    }

    @Override
    protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
        return computeWidth(n -> n.prefWidth(height - topInset - bottomInset)) ;
    }


    private double computeWidth(ToDoubleFunction<Node> width) {
        double navWidth = width.applyAsDouble(navigator);
        double max = 0 ;
        for (Tab tab : getSkinnable().getTabs()) {
            double tabWidth = width.applyAsDouble(tab.getContent());
            max = Math.max(max, tabWidth);
        }
        return navWidth + max ;
    }

    private double computeHeight(ToDoubleFunction<Node> height) {
        double max = height.applyAsDouble(navigator) ;
        for (Tab tab : getSkinnable().getTabs()) {
            max = Math.max(max, height.applyAsDouble(tab.getContent()));
        }
        return max ;
    }
}

这会创建一个 ListView 并对侦听器和绑定做一些小魔法,以确保它始终与选项卡窗格中的选项卡列表具有相同的内容,并且列表视图中的选定项目是选定的选项卡。 (如果以编程方式更改所选选项卡,您需要确保更新列表视图,并确保如果用户更改列表视图中的所选项目,则更改所选选项卡。)其余的只是覆盖 layoutChildren()方法和计算 min/max/pref 大小的各种方法。

结果是传统的 "navigation pane":

当然,因为所有选项卡内容都加载一次,并且只是切换到视图和退出视图(通过更改它们的可见性),恢复到之前的视图时丢失数据的问题就消失了。