Java の GUI は Swing の時代からプラットフォーム ・ ネイティブの UI とは異なる部分がありました。このような Java GUI の傾向は JavaFX になっても相変わらずで、 ネイティブ UI との違いに戸惑ったり違和感を持つことがあります。
その中でも、 JavaFX のコンテキストメニューの振る舞いはとても不自然で不満を抱えている人も多いと思います。(ネイティブと振る舞いが異なるというだけでなく、 JavaFX コンテキストメニューの振る舞いは合理的ではなく明らかに不自然なのです。)
コンテキストメニューが抱えているいくつかの問題は古くからバグレポートに上がっているのですが、 5 年以上経った今でも未解決のままです。
このまま待っていても解決は期待できそうもないので、 コンテキストメニューの不自然な振る舞いを簡単なハックで修正してしまいましょう。
- この記事の内容はJavaFX 8には適用できません
- この記事では JavaFX 11 を対象にして書かれました。
API の一部が異なるため、 JavaFX 8 にはそのまま適用できません。
JavaFX コンテキストメニューの何が不自然なのか
最大の問題は 「マウスカーソルがメニューアイテムの外側に出ても選択状態が解除されないこと」 です。この問題がいくつかの不自然な振る舞いの原因になっています。
JavaFX ではメニューアイテム上でマウスボタンを押し下げてしまうと、 もうキャンセルできません。JavaFX 以外の一般的なメニューではマウスボタンを誤って押し下げてしまってもメニューからマウスカーソルを外してマウスボタンを離せばメニューアクションは発動しません。しかし、 JavaFX ではマウスカーソルを外してもメニューアイテムの選択状態が解除されないため、 メニューの外側でマウスボタンを離しても選択状態になっているメニューアイテムのアクションが発動してしまうのです。
メニューアクションをキャンセルする機会が与えられていないことはユーザーにとってストレスになります。
マウスカーソルが外れたらメニューの選択状態を解除する
というわけで、 マウスカーソルが外れたら選択状態が解除されるコンテキストメニューを作っていきましょう。
基本的な考え方は簡単です。メニューアイテムのノードにマウスイベントハンドラーを登録して、 マウスカーソルが外れたとき (MOUSE_EXITED
イベントが発生したとき) にフォーカスを放棄すれば OK です。
試しに、 MenuItem
の addEventHandler
メソッドで MOUSE_EXITED
イベントで呼び出されるハンドラーを登録してみましょう。
MenuItem ではマウスイベントが発生しないMenuItem menuItem = ...;
menuItem.addEventHandler(MouseEvent.MOUSE_EXITED, mouseEvent -> {
System.out.println("メニューアイテムからマウスカーソルが外れました");
});
しかし、 上記のコードではマウスカーソルが外れてもイベントハンドラーが呼ばれることはありませんでした。MenuItem
はメニューアイテムの論理的な構造を表わすクラスであり外観を持つノードではないからです。
マウスイベントハンドラーはノードに登録しなければなりません。
MenuItem
の getStyleableNode
メソッドで実際のノードを参照することができるので、 マウスイベントハンドラーは MenuItem
ではなく MenuItem#getStyleableNode
が返すノードに登録する必要があります。
MenuItem.getStyleableNode() ならマウスイベントが発生するMenuItem menuItem = ...;
Node menuItemNode = menuItem.getStyleableNode();
menuItemNode.addEventHandler(MouseEvent.MOUSE_EXITED, mouseEvent -> {
System.out.println("メニューアイテムからマウスカーソルが外れました");
});
これで、 メニューアイテムからマウスカーソルが外れたことを検出できるようになるのですが、 もう一つ問題があります。それは 「ノードの作られるタイミング」 です。JavaFX の画面を構築した時点ではメニューアイテムのノードは作成されていません。getStyleableNode
メソッドを呼んでも null
が返されてしまうのです。
メニューアイテムのノードはコンテキストメニュー初回表示時に作成されます。
そのため、 コンテキストメニューが表示されるときにメニューアイテムのノードにマウスイベントハンドラーを登録していく必要があります。つまり、 コンテキストメニューに WindowEvent.WINDOW_SHOWING
イベントで呼び出されるハンドラーを登録して、 その中でメニューアイテムノードに対する処置をしなければなりません。
コンテキストメニューが表示されるときに処理をするContextMenu contextMenu = ...;
contextMenu.addEventHandler(
WindowEvent.WINDOW_SHOWING, new EventHandler<WindowEvent>() {
@Override
public void handle(WindowEvent windowEvent) {
for (MenuItem menuItem : contextMenu.getItems()) {
Node menuItemNode = menuItem.getStyleableNode();
//
// ここでは menuItemNode が作成されているので、
// ノードに対してイベントハンドラー登録ができます。
//
}
// 初回表示時にイベントハンドラーが呼び出されれば十分です。
// イベントハンドラーが繰り返し呼び出される必要はないので
// WINDOW_SHOWINGイベントハンドラーの登録を解除します。
contextMenu.removeEventHandler(WindowEvent.WINDOW_SHOWING,this);
}
});
フォーカスを放棄する方法
JavaFX の Node
には requestFocus
というメソッドがあり、 これを呼び出すことでフォーカスを要求することができます。ですが、 releaseFocus
のようなフォーカスを放棄するためのメソッドは用意されていません。
ノードからフォーカスを外すためには、 他の適当なノードで requestFocus
を呼びましょう。そうすれば目的のノードからはフォーカスが外れてくれます。今回は、 メニューアイテムノードの親であるコンテキストメニューノードにフォーカスを与えるのが良いでしょう。
メニューアイテムからフォーカスを外すContextMenu contextMenu = ...;
Node contextMenuNode = contextMenu.getStyleableNode();
MenuItem menuItem = ...;
Node menuItemNode = menuItem.getStyleableNode();
menuItemNode.addEventHandler(MouseEvent.MOUSE_EXITED, mouseEvent -> {
// コンテキストメニュー(ノード)がフォーカスを要求することで
// メニューアイテム(ノード)からフォーカスが外れます。
contextMenuNode.requestFocus();
});
メニューアイテムのアクション発動をキャンセルする
最後に、 マウスボタンが離されたときにメニューアイテムがフォーカスされていなければアクション発動をキャンセルするようにしましょう。これを実現するためにイベントハンドラーではなくイベントフィルターを登録します。addEventHandler
メソッドではなく addEventFilter
メソッドになっていることに注目してください。
MenuItem menuItem = ...;
Node menuItemNode = menuItem.getStyleableNode();
menuItemNode.addEventFilter(MouseEvent.MOUSE_RELEASED, mouseEvent -> {
if (!menuItemNode.isFocused()) {
mouseEvent.consume();
}
});
上記のコードではマウスボタンが離された (MouseEvent.MOUSE_RELEASED
) ときに、 メニューアイテム (ノード) のフォーカス状態を確認して非フォーカス状態 (つまりメニューアイテムの外側にマウスカーソルが出た状態) であればイベントを消費するようにしています。イベントフィルターでイベントを消費すると、 そのイベントは伝搬しなくなりイベントハンドラーは呼ばれなくなります。これにより、 JavaFX メニューアイテム (ノード) の既定のアクションの発動を阻止することができます。
完成したサンプルプログラム
こうして、 マウスカーソルを外すと選択状態が解除されるメニューが完成しました。この振る舞いの修正はコンテキストメニューだけでなくメニューバーのポップアップメニューにも適用できます。
SampleApp.javapackage com.example;
import javafx.application.Application;
import javafx.event.EventHandler;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.Label;
import javafx.scene.control.MenuItem;
import javafx.scene.input.MouseEvent;
import javafx.stage.Stage;
import javafx.stage.WindowEvent;
public class SampleApp extends Application {
public static void main(String[] args) {
launch(args);
}
@FXML Label lblSample;
@FXML MenuItem menuItemOpen;
@Override
public void start(Stage primaryStage) throws Exception {
primaryStage.setTitle("JavaFX でコンテキスト・メニューの振る舞いを修正する");
FXMLLoader loader = new FXMLLoader(getClass().getResource("SampleApp.fxml"));
loader.setController(this);
Parent root = loader.load();
Scene scene = new Scene(root);
primaryStage.setScene(scene);
primaryStage.show();
// lblSampleのコンテキストメニューの振る舞いを修正します。
fix(lblSample.getContextMenu(), true);
// メニューバーの振る舞いを修正します。
fix(menuItemOpen.getParentPopup(), false);
}
private static void fix(ContextMenu contextMenu, boolean hideOnMouseReleased) {
contextMenu.addEventHandler(WindowEvent.WINDOW_SHOWING, new EventHandler<WindowEvent>() {
@Override
public void handle(WindowEvent windowEvent) {
Node contextMenuNode = contextMenu.getStyleableNode();
for (MenuItem menuItem : contextMenu.getItems()) {
Node menuItemNode = menuItem.getStyleableNode();
if (menuItemNode == null) {
continue;
}
// マウスカーソルがメニューアイテムの外側に出てとき、
// 他ノードにフォーカス要求を出すことでメニューアイテムからフォーカスを外します。
// ここでは他ノードとして親であるcontextMenuNodeを指定してます。(他の適当なノードでもOK)
menuItemNode.addEventHandler(MouseEvent.MOUSE_EXITED, mouseEvent -> {
contextMenuNode.requestFocus();
});
// メニューアイテムで押下されたマウスボタンが離されたとき、
// メニューアイテムがフォーカスされていなければ、マウスイベントを消費します。
// これによってメニューアイテムのアクション発動を抑止することができます。
// また、hideOnMouseReleased の指定に従ってコンテキスト・メニューを非表示にします。
menuItemNode.addEventFilter(MouseEvent.MOUSE_RELEASED, mouseEvent -> {
if (!menuItemNode.isFocused()) {
mouseEvent.consume();
if (hideOnMouseReleased) {
contextMenu.hide();
}
}
});
}
contextMenu.removeEventHandler(WindowEvent.WINDOW_SHOWING,this);
}
});
}
}
SampleApp.fxml<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<StackPane xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml"
stylesheets="@SampleApp.css"
prefWidth="420.0" prefHeight="240.0">
<BorderPane>
<top>
<MenuBar>
<menus>
<Menu text="ファイル">
<MenuItem fx:id="menuItemOpen" text="ファイルを開く..."/>
<MenuItem fx:id="menuItemSaveAs" text="名前を付けて保存..."/>
<SeparatorMenuItem/>
<MenuItem fx:id="menuItemExit" text="終了"/>
</Menu>
</menus>
</MenuBar>
</top>
<center>
<VBox alignment="TOP_CENTER">
<Label fx:id="lblSample"
text="ここを右クリックするとコンテキスト・メニューが表示されます">
<contextMenu>
<ContextMenu>
<items>
<MenuItem text="新規作成"/>
<MenuItem text="編集"/>
<MenuItem text="削除"/>
</items>
</ContextMenu>
</contextMenu>
</Label>
</VBox>
</center>
</BorderPane>
</StackPane>
SampleApp.css.root {
-fx-font-family: "Meiryo";
-fx-font-size: 12px;
}
#lblSample {
-fx-padding: 10;
-fx-background-color: #E0E0E0;
}
作成したサンプルプログラムは以下のリンクからダウンロードできます。([Gradle]プロジェクト形式になっています。)