JavaFX コンテキストメニューの不自然な振る舞いを直す

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.java
package 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 プロジェクト形式になっています

contextmenu-sample.zip (62KB)

この記事を共有しませんか?