2

I want to know if there's a simple way to link the functionality of two nodes in different stages in JavaFX.

In one stage, I have a Menu containing two radioMenuItem's in a ToggleGroup together which control the theme of the app:

BorderPane mainPane = new BorderPane();
MenuBar menuBar = new MenuBar();
menuBar.setStyle("-fx-base: white");
Menu options = new Menu("Options");
MenuItem appearance = new MenuItem("Appearance");

Menu themes = new Menu("Theme");
ToggleGroup toggleGroup = new ToggleGroup();
RadioMenuItem dark = new RadioMenuItem("Dark");
dark.setToggleGroup(toggleGroup);
RadioMenuItem light = new RadioMenuItem("Light");
light.setToggleGroup(toggleGroup);
themes.getItems().addAll(dark, light);

options.getItems().addAll(appearance, themes);
menuBar.getMenus().addAll(options);
mainPane.setTop(menuBar);

This pane belongs to the Stage that opens when the app launches. When a new window opens, I want the user to be able to control the theme from that window as well. I tried adding the same items to the Pane in that Stage, but then they get removed from the Pane in this one. I don't want to create new objects for every window, since they would be exactly the same, just in a different Pane and Stage.

2 Answers2

4

A node can only be attached to a single parent at any given time.

From the Node javadoc:

A node may occur at most once anywhere in the scene graph. Specifically, a node must appear no more than once in all of the following: as the root node of a Scene, the children ObservableList of a Parent, or as the clip of a Node.

If a program adds a child node to a Parent (including Group, Region, etc) and that node is already a child of a different Parent or the root of a Scene, the node is automatically (and silently) removed from its former parent. If a program attempts to modify the scene graph in any other way that violates the above rules, an exception is thrown, the modification attempt is ignored and the scene graph is restored to its previous state.

It is possible to rearrange the structure of the scene graph, for example, to move a subtree from one location in the scene graph to another. In order to do this, one would normally remove the subtree from its old location before inserting it at the new location. However, the subtree will be automatically removed as described above if the application doesn't explicitly remove it.

What you wish to do, simultaneously have the same node attached to two different scenes, is impossible by design.

jewelsea
  • 150,031
  • 14
  • 366
  • 406
2

This question is really two questions, so I will provide two answers.

  1. The first question is the question from the question title, which is whether on not you can place the same node in different scenes?

  2. The second question is that, given that you can't place the same node in different stages, how do you synchronize the state of the application across multiple stages and views?

    • you use a shared model as explained in this answer.

General Approach

The answer is to apply MVC principles, similar to those outlined in:

With this approach, you have a shared model class. Each view in the UI listens to the shared model and can update its view state when a change in the model is detected. The views can also update the model, which will trigger updates on other views of the model.

Specific Theme Example

The rest of the answer provides a concrete example of the application of a shared model state for an application-wide theme style that can be modified from multiple different UI elements.

The solution shows two windows, each with its own separate menu controls for the application:

  1. A File menu which allows the user to quit the application.
  2. A Theme menu which allows the user to select between a Light or a Dark theme.

The theme selection will immediately apply across all windows in the application and all radio menu items in the application will be updated to reflect the new theme. This is accomplished through a shared Theme model class with a listenable selected theme property. When the user selects a new theme, the shared selected theme property is updated to the new value. Listeners on the theme property are then fired to update the UI state and theme as appropriate.

Menu items in macs can be system menu items disassociated with the stage. So, there is a line in the solution which can be uncommented if you want to use the shared system menu on a Mac (recommended).

The solution could be made more abstract and configurable to deal with additional themes or to work via interfaces to perform tasks such as registering listeners and invoking callbacks. For example, subclassable ThemedStages or a decoration pattern could be provided that shares themeable logic, or a shared ThemeManager could be created to act as a controller to register and unregister themed stages and apply theme changes to them. But, for this example, I tried not to make it too abstract. Hopefully, that allows it to be easy to understand.

The example also inlines the CSS for convenience, but you can have the themes in separate stylesheets if desired.

Sometimes you can use binding to help support the interface of the model with the UI components. But here, there is not a one-to-one match between the model and the UI control properties, so listeners are used instead.

Stage menus light theme

stage menu light theme 1 stage menu light theme 2

Stage menus dark theme

stage menu dark 1 stage menu dark 2

Mac system menu light theme

mac menu light

Mac system menu dark theme

mac menu dark

Example Code

import javafx.application.Application;
import javafx.application.Platform;
import javafx.beans.property.*;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;

public class ThemedMultiStageApp extends Application {
    @Override
    public void start(Stage stage) throws Exception {
        Theme theme = new Theme();

        StageOne stageOne = new StageOne(theme);
        stageOne.show();

        StageTwo stageTwo = new StageTwo(theme);
        stageTwo.setX(stageOne.getX() + stageOne.getWidth() + 20);
        stageTwo.setY(stageOne.getY());
        stageTwo.show();
    }

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

class StageOne extends Stage {
    public StageOne(Theme theme) {
        StackPane view = new StackPane(
                new Button("Button One")
        );

        BorderPane layout = new BorderPane();
        layout.setTop(new AppMenuBar(theme));
        layout.setCenter(view);

        Scene scene = new Scene(
                layout, 200, 150
        );

        theme.applyTo(scene);
        theme.themeProperty().addListener((observable, oldTheme, newValue) ->
            theme.applyTo(scene)
        );

        setScene(scene);
    }
}

class StageTwo extends Stage {
    public StageTwo(Theme theme) {
        StackPane view = new StackPane(
                new Button("Button Two")
        );

        BorderPane layout = new BorderPane();
        layout.setTop(new AppMenuBar(theme));
        layout.setCenter(view);

        Scene scene = new Scene(
                layout, 200, 150
        );

        theme.applyTo(scene);
        theme.themeProperty().addListener((observable, oldTheme, newValue) ->
                theme.applyTo(scene)
        );

        setScene(scene);
    }
}

class AppMenuBar extends MenuBar {
    public AppMenuBar(Theme theme) {
        // File menu
        MenuItem exitMenuItem = new MenuItem("Exit");
        exitMenuItem.setOnAction(e -> Platform.exit());
        Menu fileMenu = new Menu(
                "File", null,
                exitMenuItem
        );

        // Theme selection menu.
        ToggleGroup themeToggleGroup = new ToggleGroup();

        RadioMenuItem lightThemeMenuItem = new RadioMenuItem("Light");
        lightThemeMenuItem.setOnAction(e -> theme.setTheme(Theme.ThemeType.LIGHT));
        lightThemeMenuItem.setToggleGroup(themeToggleGroup);

        RadioMenuItem darkThemeMenuItem = new RadioMenuItem("Dark");
        darkThemeMenuItem.setOnAction(e -> theme.setTheme(Theme.ThemeType.DARK));
        darkThemeMenuItem.setToggleGroup(themeToggleGroup);

        setThemeToggle(
                theme.getTheme(), themeToggleGroup, lightThemeMenuItem, darkThemeMenuItem
        );
        theme.themeProperty().addListener((observable, oldTheme, newTheme) ->
                setThemeToggle(
                        newTheme, themeToggleGroup, lightThemeMenuItem, darkThemeMenuItem
                )
        );

        Menu themeMenu = new Menu("Theme");
        themeMenu.getItems().addAll(lightThemeMenuItem, darkThemeMenuItem);

        // If you want to use the system menu bar on a Mac (recommended)
        // then uncomment this line.
        // Note, the useSystemMenuBar setting will have no effect on a
        // platforms which do not support system menu bars (e.g. Windows).
        
        // setUseSystemMenuBar(true);
        
        getMenus().setAll(
                fileMenu, themeMenu
        );
        setMinSize(MenuBar.USE_PREF_SIZE, MenuBar.USE_PREF_SIZE);
    }

    private void setThemeToggle(Theme.ThemeType newTheme, ToggleGroup themeToggleGroup, RadioMenuItem lightThemeMenuItem, RadioMenuItem darkThemeMenuItem) {
        switch(newTheme) {
            case LIGHT -> themeToggleGroup.selectToggle(lightThemeMenuItem);
            case DARK -> themeToggleGroup.selectToggle(darkThemeMenuItem);
        }
    }
}

class Theme {
    public enum ThemeType {
        LIGHT, DARK
    }

    private static final String INDIA_INK = "#383d48";

    private static final String CSS_TEMPLATE =
            """
            data:text/css,
            .root {
                -fx-font-size: 16px;
                -fx-base: %s;
            }
            """;

    private static final String DARK_CSS =
            CSS_TEMPLATE.formatted(INDIA_INK);

    private static final String LIGHT_CSS =
            CSS_TEMPLATE.formatted("antiquewhite");

    private static final String[] THEME_STYLESHEETS = { LIGHT_CSS, DARK_CSS };

    private final ObjectProperty<ThemeType> theme = new SimpleObjectProperty<>(ThemeType.LIGHT);

    public ThemeType getTheme() {
        return theme.get();
    }

    public ObjectProperty<ThemeType> themeProperty() {
        return theme;
    }

    public void setTheme(ThemeType theme) {
        this.theme.set(theme);
    }

    public void applyTo(Scene scene) {
        scene.getStylesheets().removeAll(THEME_STYLESHEETS);
        scene.getStylesheets().add(
                switch (theme.get()) {
                    case LIGHT -> LIGHT_CSS;
                    case DARK -> DARK_CSS;
                }
        );
    }
}
jewelsea
  • 150,031
  • 14
  • 366
  • 406