JavaFX TreeViewのカスタマイズ - その3：TreeCell用Graphicの作成

[前回](http://qiita.com/sipadan2003/items/4b909a294b039b0bfd2f "前回")までにTreeViewで使うデータの例と、ラベルの編集ができるTreeCellの例を示しました。
今回は、ImageViewとTextField両方を並べて表示し、TextFieldで発生するイベントをハンドリングするTextCellGraphクラスを紹介します。

[JavaFX Documentation](http://docs.oracle.com/javafx/ "JavaFX Documentation")の[Tree View](http://docs.oracle.com/javafx/2/ui_controls/tree-view.htm "Tree View")のExample 13-3を見てください。TextFieldTreeCellImplのcreateTextField()にて、ENTERまたはESCのキー押下時のハンドラを登録しています。でも、ハンドラの処理内容がちょっと物足りないです。次の図を見てください。

![ラベルが空](https://qiita-image-store.s3.amazonaws.com/0/33023/dfcff725-b7e2-7fb3-5576-0169b4066688.gif "ラベルが空")

編集中のラベル名が空です。一般的にラベル名は必須で空文字は禁止というのが多いと思います。このような状態でENTERキーを押下した場合の処理が必要でしょう。次の図も見てください。

![ラベル名が重複](https://qiita-image-store.s3.amazonaws.com/0/33023/3199f1d6-8062-aef6-bfa4-656fa3af2a59.gif "ラベル名が重複")

編集中のラベル名が「Node00」ですが、すぐ上のTreeCellのラベル名も「Node00」です。同じ親に属する他のラベル名と重複するのを禁止したいとアプリケーションも多いのではないでしょうか。

また、ラベルを編集しているときに、マウスで別の所をクリックする場合などでフォーカスを失うときがあります。この場合の処理も必要です。

では、TextCellGraphのコードを示します。
ImageViewとTextField両方を横に並べて表示するためにGridPaneの派生クラスとしています。

```java:TreeCellGraph.java
package jfxtreeview;

import java.util.Iterator;

import javafx.application.Platform;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.event.EventHandler;
import javafx.scene.control.TextField;
import javafx.scene.control.TreeItem;
import javafx.scene.image.ImageView;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.GridPane;

/**
 *TreeCellが編集モードのときに使用するGraphです。
 */
final class TreeCellGraph extends GridPane {
	private final TreeViewController controller;
	private final TreeCellImpl cell;
	private final TextField textField = new TextField();
	private TextFocusHandler focusHandler;

	/**
	 *コンストラクタ。
	 *@param controller TreeViewコントローラ
	 *@param cell TreeCellオブジェクト
	 *@param imageView 編集時のアイコン用ImageView
	 */
	TreeCellGraph(TreeViewController controller, TreeCellImpl cell, ImageView imageView){
		this.controller = controller;
		this.cell = cell;
		this.textField.setOnKeyPressed(new TextFieldKeyPressedHandler());
		add(imageView, 0, 0);
		add(this.textField, 1, 0);
	}

	/**
	 *TreeCellが編集モードになるときに、TreeCellImplから呼び出される。 
	 */
	void startEdit(){
		final TextField textField = this.textField;
		//フォーカスハンドラの登録
		this.focusHandler = new TextFocusHandler();
		textField.focusedProperty().addListener(this.focusHandler);
		//TextFieldに文字列を設定
		textField.setText(this.cell.getItem().getName());
		//遅延してselectAll->requestFocusの順で呼び出す
		Platform.runLater(new Runnable(){
			@Override
			public void run(){
				textField.selectAll();
				textField.requestFocus();
			}
		});
	}

	/**
	 *EnterまたはESCキー押下時に呼び出される。 
	 */
	private void endingEditByKeyboard(){
		//名前が空なら、編集を継続
		final TextField textField = this.textField;
		final String currentText = textField.getText();
		if(currentText.isEmpty()){
			return;
		}
		//名前に変更が無ければ、編集をキャンセル
		final TreeCellImpl cell = this.cell;
		final String prevText = cell.getItem().getName();
		if(currentText.equals(prevText) == true){
			removeFocusHandler();
			cell.cancelEdit();
			return;
		}
		//兄弟に同じ名前が無いかどうかチェックする
		final TreeItem<TreeItemData> treeItem = cell.getTreeItem();
		final Iterator<TreeItem<TreeItemData>> children = treeItem.getParent().getChildren().iterator();
		while(children.hasNext()){
			final TreeItem<TreeItemData> child = children.next();
			//同じ名前が存在する場合、編集を継続
			if(treeItem != child && currentText.equals(child.getValue().getName())){
				return;
			}
		}
		//Controllerのコールバックを呼び出す
		final TreeItemData newTreeItemData = this.controller.treeItemDataRenamed(treeItem, currentText);
		//コールバックの復帰値がnullの場合、新しい名前が受け入れなかったと判断し、編集をキャンセルする
		if(newTreeItemData == null){
			removeFocusHandler();
			cell.cancelEdit();
			return;
		}
		//編集を確定する
		removeFocusHandler();
		cell.commitEdit(newTreeItemData);
	}

	/**
	 *フォーカスを失ったときに呼び出される。 
	 */
	private void endingEditByLostingFocus(){
		//名前が空なら、編集をキャンセル
		final TextField textField = this.textField;
		final String currentText = textField.getText();
		if(currentText.isEmpty()){
			removeFocusHandler();
			cell.cancelEdit();
			return;
		}
		//名前に変更が無ければ、編集をキャンセル
		final TreeCellImpl cell = this.cell;
		final String prevText = cell.getItem().getName();
		if(currentText.equals(prevText) == true){
			removeFocusHandler();
			cell.cancelEdit();
			return;
		}
		//兄弟に同じ名前が無いかどうかチェックする
		final TreeItem<TreeItemData> treeItem = cell.getTreeItem();
		final Iterator<TreeItem<TreeItemData>> children = treeItem.getParent().getChildren().iterator();
		while(children.hasNext()){
			final TreeItem<TreeItemData> child = children.next();
			//同じ名前が存在する場合、編集をキャンセル（暫定処理）
			if(treeItem != child && currentText.equals(child.getValue().getName())){
				removeFocusHandler();
				cell.cancelEdit();
				return;
			}
		}
		//Controllerのコールバックを呼び出す
		final TreeItemData newTreeItemData = this.controller.treeItemDataRenamed(treeItem, currentText);
		//コールバックの復帰値がnullの場合、新しい名前が受け入れなかったと判断し、編集をキャンセルする
		if(newTreeItemData == null){
			removeFocusHandler();
			cell.cancelEdit();
			return;
		}
		//編集を確定する
		removeFocusHandler();
		cell.commitEdit(newTreeItemData);
	}

	/**
	 *フォーカスハンドラを削除する。
	 *ENTER/ESC押下後のTreeCell.commitEdit()またはcancelEdit()の
	 *呼び出しによりフォーカスを失っても
	 *フォーカスハンドラを呼び出さないようにするため。
	 */
	private void removeFocusHandler(){
		this.textField.focusedProperty().removeListener(this.focusHandler);
		this.focusHandler = null;
	}

	/**
	 *編集中にENTER/ESCが押下されたら、編集終了。
	 */
	final class TextFieldKeyPressedHandler implements EventHandler<KeyEvent> {
		@Override
		public void handle(KeyEvent e) {
			switch(e.getCode()){
			case ENTER:
				TreeCellGraph.this.endingEditByKeyboard();
				break;
			case ESCAPE:
				TreeCellGraph.this.cell.cancelEdit();
				break;
			default:
				//NOP
			}
		}
	}

	/**
	 *編集中にフォーカスを失ったら、編集終了。
	 */
	final class TextFocusHandler implements ChangeListener<Boolean>{
		@Override
		public void changed(ObservableValue<? extends Boolean> o, Boolean b1, Boolean b2) {
			if(b2==false){
				TreeCellGraph.this.endingEditByLostingFocus();
			}
		}
	}
}
```

コード内にコメントを入れてありますので、参考にしてください。

startEdit()内にTextFieldのselectAll()とrequestFocus()を呼び出すコードがありますが、startEdit()が呼び出されたタイミングで実行してもうまく効かないようです。
苦肉の策として、JavaFXのスレッド（AWT/Swingのイベントディスパッチスレッドに相当）を使って遅延実行させるためにPlatform.runLater()を使っています（たぶん画面に表示した後だと効く）。
（ラムダを使えって…）

```java:startEdit()から抜粋
		//遅延してselectAll->requestFocusの順で呼び出す
		Platform.runLater(new Runnable(){
			@Override
			public void run(){
				textField.selectAll();
				textField.requestFocus();
			}
		});
```

また、endingEditByKeyboard()とendingEditByLostingFocus()の内容は似ているようで微妙に違いますので見比べてください。EnterまたはESC押下時は、条件に応じてTreeCellのcommitEdit()またはcancelEdit()を呼び出さずに、編集状態を続行できます。しかし、フォーカスを失う場合は、必ずcommitEdit()またはcancelEdit()を必ず呼び出して、素直にフォーカスを解放するのが無難です。本当はもう少し作りこんでユーザフレンドリーなUIにすべきですが、今回はこれくらいにします。この2つのメソッドにTreeViewControllerのtreeItemDataRenamed()を呼び出す処理があります。

```java:TreeViewコントローラの呼び出し
		this.controller.treeItemDataRenamed(treeItem, currentText);
```

TreeViewControllerは次回で紹介しますが、TreeView全般を制御するコントローラです。Windowsのエクスプローラを例にして考えると、ラベル名を変更したら、ファイルシステム上のフォルダの名前も変更する必要があります。上のtreeItemDataRenamed()は、このような処理を行うためにあると考えてください。

TreeViewController、TreeView、TreeCellImpl(TreeCellGraph含む)、TreeItemおよびTreeItemDataの関係図を下に示します。皆さんご存知のMVCパターンです。

![MVCパターン](https://qiita-image-store.s3.amazonaws.com/0/33023/c5c8f780-1a70-6c37-e6b6-d42cf06eb646.gif "MVCパターン")

次回はTreeViewControllerを紹介します。