Andy
Andy

Reputation: 518

How to create a custom dialog in JavaFx without using any "ButtonType"-controls?

I want to create a custom Dialog, which just displays options (see figure 1). If the user selects one of those options, the dialog should close and return the corresponding result instantly.

So far, I can only accomplish this by adding an arbitrary ButtonType to the Dialog, hiding it by using setVisible(false) and applying fire() in the EventHandler of the clicked option.

This weird workaround actually works fine, but seems to me very unprofessional ...
So, how to do this in a more professional or proper way without using the ButtonType trick?

figure 1

My workaround-code looks like this (Dialog class):

public class CustomDialog extends Dialog<String> {

    private static final String[] OPTIONS
            = new String[]{"Option1", "Option2", "Option3", "Option4"};
    private String selectedOption = null;
    Button applyButton;

    public CustomDialog() {
        super();
        initStyle(StageStyle.DECORATED);
        VBox vBox = new VBox();
        for (String option : OPTIONS) {
            Button optionButton = new Button(option);
            optionButton.setOnAction((event) -> {
                selectedOption = option;
                applyButton.fire();
            });
            vBox.getChildren().add(optionButton);
        }
        getDialogPane().setContent(vBox);
        getDialogPane().getButtonTypes().add(ButtonType.APPLY);
        applyButton = (Button) getDialogPane().lookupButton(ButtonType.APPLY);
        applyButton.setVisible(false);

        setResultConverter((dialogButton) -> {
            return selectedOption;
        });
    }
}

Using the dialog class:

    CustomDialog dialog = new CustomDialog();
    Optional<String> result = dialog.showAndWait();
    String selected = null;
    if (result.isPresent()) {
        selected = result.get();
    } else if (selected == null) {
        System.exit(0);
    }

Upvotes: 2

Views: 8753

Answers (3)

James_D
James_D

Reputation: 209299

A Dialog is just a window displaying a DialogPane, and, quoting the Javadocs for DialogPane:

DialogPane operates on the concept of ButtonType. A ButtonType is a descriptor of a single button that should be represented visually in the DialogPane. Developers who create a DialogPane therefore must specify the button types that they want to display

(my emphasis). Therefore, while you've shown one possible workaround and in the other answer Slaw has shown another, if you're trying to use a Dialog without using ButtonType and its associated result converter, you're really using the Dialog class for something for which it's not intended.

The functionality you describe is perfectly achievable with a regular modal Stage. For example, the following gives the same basic behavior you describe and involves no ButtonTypes:

package org.jamesd.examples.dialog;

import java.util.Optional;
import java.util.stream.Stream;

import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.layout.VBox;
import javafx.stage.Modality;
import javafx.stage.Stage;
import javafx.stage.Window;

public class CustomDialog {

    private static final String[] OPTIONS 
        = {"Option 1", "Option 2", "Option 3", "Option 4"};

    private final Stage stage ;

    private String selectedOption = null ;

    public CustomDialog() {
        this(null);
    }

    public CustomDialog(Window parent) {
        var vbox = new VBox();
        // Real app should use an external style sheet:
        vbox.setStyle("-fx-padding: 12px; -fx-spacing: 5px;");
        Stream.of(OPTIONS)
            .map(this::createButton)
            .forEach(vbox.getChildren()::add);
        var scene = new Scene(vbox);
        stage = new Stage();
        stage.initOwner(parent);
        stage.initModality(Modality.WINDOW_MODAL);
        stage.setScene(scene);
    }

    private Button createButton(String text) {
        var button = new Button(text);
        button.setOnAction(e -> {
            selectedOption = text ;
            stage.close();
        });
        return button ;
    }

    public Optional<String> showDialog() {
        selectedOption = null ;
        stage.showAndWait();
        return Optional.ofNullable(selectedOption);
    }
}

Here's a simple application class which uses this custom dialog:

package org.jamesd.examples.dialog;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;

public class App extends Application {

    @Override
    public void start(Stage stage) throws Exception {
        var root = new VBox();
        // Real app should use an external style sheet:
        root.setStyle("-fx-padding: 12px; -fx-spacing: 5px;");
        var showDialog = new Button("Show dialog");
        var label = new Label("No option chosen");
        showDialog.setOnAction(e -> 
            new CustomDialog(stage)
                .showDialog()
                .ifPresentOrElse(label::setText, Platform::exit));
        root.getChildren().addAll(showDialog, label);
        stage.setScene(new Scene(root));
        stage.show();
    }

}

Upvotes: 9

Slaw
Slaw

Reputation: 45746

As pointed out by both @Sedrick and @James_D, the Dialog API is built around the concept of "button types". Not using ButtonType goes against the API and, because of this, will always seem hacky/wrong. That said, there is a slight alteration you could make to your current code that satisfies your "without using any 'ButtonType'-controls" goal. It doesn't appear to be documented, but if you set the result property manually it triggers the close-and-return-result process. This means you don't need to add any ButtonType and can bypass the resultConverter completely. Here's a proof-of-concept:

OptonsDialog.java:

import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.Dialog;
import javafx.scene.layout.VBox;

public class OptionsDialog<T extends OptionsDialog.Option> extends Dialog<T> {

  public interface Option {

    String getDisplayText();
  }

  @SafeVarargs
  public OptionsDialog(T... options) {
    if (options.length == 0) {
      throw new IllegalArgumentException("must provide at least one option");
    }

    var content = new VBox(10);
    content.setAlignment(Pos.CENTER);
    content.setPadding(new Insets(15, 25, 15, 25));

    for (var option : options) {
      var button = new Button(option.getDisplayText());
      button.setOnAction(
          event -> {
            event.consume();
            setResult(option);
          });
      content.getChildren().add(button);
    }

    getDialogPane().setContent(content);
  }
}

App.java:

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.control.Button;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;
import javafx.stage.Window;

public class App extends Application {

  private enum Foo implements OptionsDialog.Option {
    OPTION_1("Option Number 1"),
    OPTION_2("Option Number 2"),
    OPTION_3("Option Number 3"),
    OPTION_4("Option Number 4");

    private final String displayText;

    Foo(String displayText) {
      this.displayText = displayText;
    }

    @Override
    public String getDisplayText() {
      return displayText;
    }
  }

  @Override
  public void start(Stage primaryStage) {
    var button = new Button("Click me!");
    button.setOnAction(
        event -> {
          event.consume();
          showChosenOption(primaryStage, promptUserForOption(primaryStage));
        });
    primaryStage.setScene(new Scene(new StackPane(button), 500, 300));
    primaryStage.show();
  }

  private static Foo promptUserForOption(Window owner) {
    var dialog = new OptionsDialog<>(Foo.values());
    dialog.initOwner(owner);
    dialog.setTitle("Choose Option");
    return dialog.showAndWait().orElseThrow();
  }

  private static void showChosenOption(Window owner, OptionsDialog.Option option) {
    var alert = new Alert(AlertType.INFORMATION);
    alert.initOwner(owner);
    alert.setHeaderText("Chosen Option");
    alert.setContentText(String.format("You chose the following: \"%s\"", option.getDisplayText()));
    alert.show();
  }
}

It's not that different from your current workaround and it's still working against the API. This also relies on undocumented behavior (that setting the result property manually closes the dialog and returns the result). The ButtonBar at the bottom still takes up some space, though less than when you add an invisible button. It's possible, however, to get rid of this empty space by adding the following CSS:

.options-dialog-pane .button-bar {
  -fx-min-height: 0;
  -fx-pref-height: 0;
  -fx-max-height: 0;
}

Note the above assumes you've modified the code to add the "options-dialog-pane" style class to the DialogPane used with the OptionsDialog.

Upvotes: 3

SedJ601
SedJ601

Reputation: 13859

I think you should read the following from the Java Docs:

Dialog Closing Rules:

It is important to understand what happens when a Dialog is closed, and also how a Dialog can be closed, especially in abnormal closing situations (such as when the 'X' button is clicked in a dialogs title bar, or when operating system specific keyboard shortcuts (such as alt-F4 on Windows) are entered). Fortunately, the outcome is well-defined in these situations, and can be best summarised in the following bullet points:

  • JavaFX dialogs can only be closed 'abnormally' (as defined above) in two situations:

    1. When the dialog only has one button, or
    2. When the dialog has multiple buttons, as long as one of them meets one of the following requirements:
      1. The button has a ButtonType whose ButtonBar.ButtonData is of type ButtonBar.ButtonData.CANCEL_CLOSE.
      2. The button has a ButtonType whose ButtonBar.ButtonData returns true when ButtonBar.ButtonData.isCancelButton() is called.
  • In all other situations, the dialog will refuse to respond to all close requests, remaining open until the user clicks on one of the available buttons in the DialogPane area of the dialog.

  • If a dialog is closed abnormally, and if the dialog contains a button which meets one of the two criteria above, the dialog will attempt to set the result property to whatever value is returned from calling the result converter with the first matching ButtonType.

  • If for any reason the result converter returns null, or if the dialog is closed when only one non-cancel button is present, the result property will be null, and the showAndWait() method will return Optional.empty(). This later point means that, if you use either of option 2 or option 3 (as presented earlier in this class documentation), the Optional.ifPresent(java.util.function.Consumer) lambda will never be called, and code will continue executing as if the dialog had not returned any value at all.

If you don't mind the Buttons being horizontal, you should use ButtonType and setResultConverter to return a String based on which button is pressed.

import javafx.scene.control.Alert;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Dialog;
import javafx.stage.StageStyle;
import javafx.util.Callback;

/**
 *
 * @author blj0011
 */
public class CustomDialog extends Dialog<String>
{
    String result = "";

    public CustomDialog()
    {
        super();
        initStyle(StageStyle.DECORATED);

        setContentText(null);
        setHeaderText(null);

        ButtonType buttonOne = new ButtonType("Option1");
        ButtonType buttonTwo = new ButtonType("Option2");
        ButtonType buttonThree = new ButtonType("Option3");
        ButtonType buttonFour = new ButtonType("Option4");

        getDialogPane().getButtonTypes().addAll(buttonOne, buttonTwo, buttonThree, buttonFour);

        setResultConverter(new Callback<ButtonType, String>()
        {
            @Override
            public String call(ButtonType param)
            {
                if (param == buttonOne) {
                    return buttonOne.getText();
                }
                else if (param == buttonTwo) {
                    return buttonTwo.getText();
                }
                else if (param == buttonThree) {
                    return buttonThree.getText();
                }
                else if (param == buttonFour) {
                    return buttonFour.getText();
                }

                return "";
            }
        });
    }
}

Update: As @Slaw stated in the comments, you can replace setResultConverter(...) with setResultConverter(ButtonType::getText).

Upvotes: 1

Related Questions