JavaFX アプリケーション

JavaFX 11以上、Scene Builder、Eclipse を利用して


数値を入力するテキストボックスにTextFormatterのFilter機能を利用する

投稿日時:

最終更新日時:

カテゴリー:

,

タグ:

    文字列を入力したい時は TextField 利用する。例えば、数値(整数、非負整数、正整数、double)を入力したい場合、まず、それらの文字列表現を TextField に入力し、それを対応する数値と解釈し、その値を入力する。この時、なるべくユーザーの負担を軽くするために、整数値を入力したい場合は、1桁目には「-」があるかもしれないが、その後は「0」から「9」までの数字であり、それ以外の文字を入力できないようにしたい。これを実現するために、TextFormatter の Filter 機能が便利である。今回は、具体例として、数値(整数、非負整数、正整数、double)の入力用に TextField の派生クラスである NumberField を作成し、その中で Filter を利用し、ユーザーの入力を制御する。TextFormatter には、例えば、数値とその文字列表現の間の変換を扱う StringConverter の機能もあるが、NumberField の setValue と getValue を適切に記述することで十分と思われるので、Filter 機能のみに焦点を当てる。また、Filter 機能を利用する際に、「文字列が正規表現に一致するか?」の話題に遭遇する。そこで、正規表現と文字列の両方を指定でき、後者が前者に一致しているか否か調べることが出来るようにもする。

    Number Formatter Filter
    NumberFormatterFilter

    上図において、下方で正規表現を入力し、文字列を入力し、Enter キーを押すと、文字列が正規表現と一致するかどうか判定できる。上方では、コンボボックスで、Integer、Nonnegative-Integer、Positive-Integer、Double、のどれかを選び、右にあるテキストフィールドに対応する数値を入力し、Enter を入力すると、「結果:」の所に入力された値が表示される。Double の場合に、13.4 等の小数点数以外に、19/3 のような分数も入力できるようにした。Filter 機能に関する説明は最後の方に記述した。

    また、次回のテーマ(TableView を利用して行列を扱う)で使うために、非負整数と正整数を保持するクラス、NonnegativeInteger と PositiveInteger を作成した。これらの必要性に関しては次回で述べる。

    Scene Builder での作業は次の図の通りである。(Eclipse の NumberFormatterFilter プロジェクトの src の zip ファイルはこちら。)

    Scene Builder ウインドウの主要部分(VBox) 変数名
    Scene Builder 1
    Scene Builder ウインドウの主要部分(VBox) Use fx:root construct をチェック
    Scene Builder 2

    次に、上記の VBox(の派生クラス)であり、このコントローラでもある NumberFormatterFilterMainVBox について述べる。コンストラクターに関しては、(ほとんど同じなので)ここを参照して下さい。

    後で詳述するが、数値の入力を行う(TextField の派生クラスである)NumberField、非負整数と正整数を保持する NonnegativeInteger、PositiveInteger が完成済みであると想定して進める。また、NumberField とのデータのやり取りは、値を設定する時は setValue(value)、値を得る時は getValue() を利用する。本来なら、NumberField を Scene Builder のライブラリに加えて、Scene Builder 上で利用したいのだが、それに成功していないので、NumberFormatterFilterMainVBox.java 上で挿入する。そのために、Scene Builder 上で この numberField を子として挿入する親を hBoxNumberFilter と名付けておいた。

    まず、comboBoxNumberFilter で選択するフィルターのタイプを表す文字列 stringsComboBoxNumberFilter と 上述の numberField を宣言する。

    private String[] stringsComboBoxNumberFilter = {"Integer", "Nonnegative-Integer", "Positive-Integer", "Double"};
    private NumberField numberField = null;
    

    初期化を行う関数 initialize() 内で、まず、comboBoxNumberFilter の内容を指定し、Integer(整数)を初期値とする。次に、NumberField の新しいインスタンスを numberField に代入し、hBoxNumberFilter に子として挿入する。その後、イベントハンドラーとイベントリスナーを設定する。

    @FXML
    private void initialize() {
    	comboBoxNumberFilter.getItems().addAll(stringsComboBoxNumberFilter);
    	comboBoxNumberFilter.setValue("Integer");
    	
    	numberField = new NumberField(NumberField.INTEGER);
    	hBoxNumberFilter.getChildren().add(numberField);
    	
    	setHandlers();
    	setListeners();
    	
    }
    

    イベントハンドラー setHandlers() では、numberField で Enter キーが押されたら、numberField の値をラベル labelNumberResult に表示する。正規表現と一致するかを調べるテキストフィールド textFieldInput で Enter キーが押されたら、textFieldInput の文字列が正規表現 textFieldRegExpression と一致するか result で調べ、その旨を labelResultOfInput に表示する。正規表現が正しくなければ例外が発生するので、その場合は、その旨表示する。

    private void setHandlers() {
    	numberField.setOnAction(new EventHandler<ActionEvent>() {
    
    		@Override
    		public void handle(ActionEvent event) {
    			labelNumberResult.setText(""+numberField.getValue());
    		}
    		
    	});
    	
    	textFieldInput.setOnAction(new EventHandler<ActionEvent>() {
    
    	@Override
    	public void handle(ActionEvent event) {
    		try {
    			boolean result = textFieldInput.getText().matches(textFieldRegExpression.getText());
    			labelResultOfInput.setText(result ? "一致します" : "一致しません");
    		} catch (Exception ex) {
    			labelResultOfInput.setText("正規表現が正しくありません!");
    		}
    	}
    		
    	});
    }
    

    イベントリスナー setListeners() では、numberField がキーボードフォーカスを得た時に、comboBoxNumberFilter で選択されている type(Integer、NonnegativeInteger、PositiveInteger、Double のどれか)を調べ、numberField をその type に設定し、既定の値、例えば、Double ならば、0.0 の値に戻す。文字列の内容である textFieldInput がキーボードフォーカスを得た時には、正規表現と一致するかどうかの結果を表示するラベル labelResultOfInput を空文字列に戻す。

    private void setListeners() {
    	numberField.focusedProperty().addListener(new ChangeListener<Boolean>() {
    
    	@Override
    	public void changed(ObservableValue<? extends Boolean> observable, Boolean oldValue, Boolean newValue) {
    		if (!oldValue && newValue) {
    			int type = comboBoxNumberFilter.getSelectionModel().getSelectedIndex();
    			numberField.setType(type);
    			switch (type) {
    			case NumberField.INTEGER:
    				numberField.setValue(Integer.valueOf(0));
    				break;
    			case NumberField.NONNEGATIVE_INTEGER:
    				numberField.setValue(NonnegativeInteger.valueOf(0));
    				break;
    			case NumberField.POSITIVE_INTEGER:
    				numberField.setValue(PositiveInteger.valueOf(1));
    				break;
    			case NumberField.DOUBLE:
    				numberField.setValue(Double.valueOf(0.0));
    				break;
    			}
    			
    			Platform.runLater(new Runnable() {
    
    				@Override
    				public void run() {
    					numberField.selectAll();
    				}
    			});
    			
    		}
    	}
    		
    	});
    	
    	textFieldInput.focusedProperty().addListener(new ChangeListener<Boolean>() {
    
    	@Override
    	public void changed(ObservableValue<? extends Boolean> observable, Boolean oldValue, Boolean newValue) {
    		if (!oldValue && newValue) labelResultOfInput.setText("");
    	}
    		
    	});
    }
    

    なお、numberField を既定値に戻した時に、それを選択された状態にする部分を Platform.runLater で処理する理由は(私の知識を超えるので)不明だが、こうすることで目的が果たせた。

    次に、非負整数を保持する NonnegativeInteger クラスについて簡単に述べる。正整数を保持する PositiveInteger はほとんど同じなので省略する。NonnegativeInteger はプロパティとして非負整数を保持する Integer 型の value を持ち、初期値は Integer.valueOf(0) である。Integer と int を引数とするコンストラクターを持ち、値が非負の場合のみ、value に代入する。setValue(Integer) も非負の場合だけ、value に代入する。getValue() は保持していた Integer 型の value を返す。toString() は value の文字列表現を返す。String と int を引数に持つ NonnegativeInteger.valueOf() を次のように定義した。

    public static NonnegativeInteger valueOf(String s) {
    	Integer integer = null;
    	try {
    		integer = Integer.valueOf(s);
    	} catch (NumberFormatException ex) {
    		throw new NumberFormatException();
    	}
    	if (isValid(integer)) {
    		return new NonnegativeInteger(integer); 
    	} else {
    		throw new NumberFormatException();
    	}
    }
    
    public static NonnegativeInteger valueOf(int i) {
    	if (isValid(i)) {
    		return new NonnegativeInteger(i);
    	} else {
    		throw new NumberFormatException();
    	}
    }
    
    private static boolean isValid(Integer integer) {
    	return isValid(integer.intValue());
    }
    
    private static boolean isValid(int i) {
    	return (i>=0);
    }
    

    すなわち、非負整数として解釈可能ならばその値を保持する NonnegativeInteger を返し、そうでなければ NumberFormatException を発生させる。以上で、Integer の特別な場合としてではなく、独立したクラスとして非負整数(正整数)を扱う NonnegativeInteger(PositiveInteger)を作成した。

    最後に、今回の本題である TextField の派生クラスである NumberField に関して述べる。これは、次の4タイプ、Integer、NonnegativeInteger、PositiveInteger、Double を入力するためのテキストフィールドで、入力を(Filter で)制御し、getValue() で入力された文字列に対応する値(Integer、NonnegativeInteger、PositiveInteger、Double のどれか)を返す。

    タイプを表す定数とそれを保持する type 等、を定義する。

    static final int INTEGER = 0;
    static final int NONNEGATIVE_INTEGER = 1;
    static final int POSITIVE_INTEGER = 2;
    static final int DOUBLE = 3;
    
    private int columnCount = 10;
    private int type = INTEGER;
    

    コンストラクタを記述する。この中で設定している textFormatter に関しては最後の方で述べる。既定のタイプは INTEGER(整数)である。途中でタイプを変える必要があるので、setType(int) も記述する。

    public NumberField(int columnCount, int type) {
    	this.type = type;
    	setAlignment(Pos.CENTER_RIGHT);
    	setPrefColumnCount(columnCount);
    	this.columnCount = columnCount;
    	setTextFormatter(textFormatter);
    }
    
    public NumberField(int type) {
    	this(10, type);
    }
    
    public NumberField() {
    	this(10, INTEGER);
    }
    
    public void setType(int type) {
    	this.type = type;
    }
    

    type を変えたら、次の setValue(Object) を呼び出して、テキストフィールドを新しい type と整合するようにする。

    public void setValue(Object o) {
    	switch(type) {
    	case INTEGER:
    		if (!(o instanceof Integer)) return;
    		break;
    	case NONNEGATIVE_INTEGER:
    		if (!(o instanceof NonnegativeInteger)) return;
    		break;
    	case POSITIVE_INTEGER:
    		if (!(o instanceof PositiveInteger)) return;
    		break;
    	case DOUBLE:
    		if (!(o instanceof Double)) return;
    		break;
    	default:
    		return;
    	}
    	setText(o.toString());
    }
    

    type に応じて、引数が適切な型、Integer、NonnegativeInteger、PositiveInteger、Double、であるかをチェックし、適切であれば、その文字列表現をテキストフィールドに入れる。(われわれの NumberFormatterFilterMainVBox.java の場合、0、1、0.0 等の既定値を入れている。)

    入力されている文字列から値を求める getValue() の説明に移る。type を調べる。例えば、INTEGER ならば、戻り値を保存する retVal に(例外が起こった場合の)既定値 Integer.valueOf(0) を代入しておく。入力された文字列を整数値として解釈するために Integer.valueOf(getText()) を求める。例外が発生しなければ、目的の値が求まったので、それを返す。もし、例外が発生したら、その旨を表示して、最初に保存した既定値を返す。NONNEGATIVE_INTEGER と POSITIVE_INTEGER も同様に扱えるように、上記の NonnegativeInteger と PositiveInteger の所で、valueOf(int) と valueOf(String) を定義しておいた。DOUBLE の場合は、少しだけ複雑である。NumberField で Double として解釈しようとする文字列は、(1)整数や小数点を含む小数、(2)「/」を含み、それよりも前は整数値である分子、それより後は正整数値としての分母、を想定している。どちらかを判別するために、まず、文字列 “/” を含むか否かを調べ、含めば分子と分母を求め(分母が 0 ならば、例外を発生させ)、割り算を実行し、その Double を求める。”/” を含まなければ、それを Double として解釈する。

    public Object getValue() {
    	Object retVal = null;
    	try {
    		switch (type) {
    		case INTEGER:
    			retVal = Integer.valueOf(0);
    			retVal = Integer.valueOf(getText());
    			break;
    		case NONNEGATIVE_INTEGER:
    			retVal = NonnegativeInteger.valueOf(0);
    			retVal = NonnegativeInteger.valueOf(getText());
    			break;
    		case POSITIVE_INTEGER:
    			retVal = PositiveInteger.valueOf(1);
    			retVal = PositiveInteger.valueOf(getText());
    			break;
    		case DOUBLE:
    			retVal = Double.valueOf(0.0);
    			String input = getText();
    			if (input.contains("/")) { // 1/10
    				int i = input.indexOf('/');
    				int numerator = Integer.valueOf(input.substring(0, i)).intValue();
    				int denominator = Integer.valueOf(input.substring(i+1)).intValue();
    				if (denominator == 0) throw new ArithmeticException();
    				retVal = Double.valueOf((double)numerator / (double)denominator);
    			} else { // 0.1
    				retVal = Double.valueOf(getText());
    			}
    			break;
    		}
    	} catch (Exception ex) {
    		showMessage(type);
    	}
    	return retVal;
    }
    
    private void showMessage(int type) {
    	Alert alert = new Alert(AlertType.INFORMATION);
    	alert.setTitle("情報");
    	String headerText = "";
    	String contentText = "";
    	switch (type) {
    	case INTEGER:
    		headerText = "整数";
    		contentText = "0";
    		break;
    	case NONNEGATIVE_INTEGER:
    		headerText = "非負整数";
    		contentText = "0";
    		break;
    	case POSITIVE_INTEGER:
    		headerText = "正整数";
    		contentText = "1";
    		break;
    	case DOUBLE:
    		headerText = "double";
    		contentText = "0.0";
    		break;
    	}
    	alert.setHeaderText("数値(" + headerText + ")の入力");
    	alert.setContentText(headerText + "として解釈できません。" + contentText + " と見なします。");
    	ButtonType buttonType = new ButtonType("了解", ButtonData.OK_DONE);
    	alert.getButtonTypes().setAll(buttonType);
    	alert.show();
    }
    

    最後に、本題の Filter の部分の説明を行う。コンストラクタの所で TextField の派生クラスである、この NumberField に設定されていた textFormatter は次のように与えられている。ここに出てくる numberFieldFilter が以下で説明する Filter であり、結局、numberField の入力を制御する Filter である。

    private TextFormatter<Object> textFormatter = new TextFormatter<Object>(numberFieldFilter);
    

    Filter では、あるテキストが削除された、あるテキストが追加された、あるテキストが置き換えられた、の変化(change)が分かるようで、細かいことまで操作ができるのであろうが、ここでは、newText = change.getControlNewText() だけを利用する。これは、このユーザーが行う入力の変化を受け入れた場合(return change; とした場合)に表示される新しいテキストの内容である。return null; とし、受け入れを拒否すると、(ユーザーによる入力の変化は起こらず 、元のままで)何も変化しない。すなわち、newText を調べ、妥当な表現ならば return change; とし、妥当でなければ return null; で拒否する。この妥当かどうかを判断する時に、与えられた正規表現と一致するかどうかも利用する。まず、利用する正規表現を定義する。

    private static String validRegINTEGER = "-?[0-9]*";
    private static String validRegNONNEGATIVE = "[0-9]*";
    private static String validRegPOSITIVE = "[0-9]*[1-9][0-9]*";
    private static String validRegDOUBLE0 = "-?[0-9]*[.]?[0-9]*"; // -2.9, .5,
    private static String validRegDOUBLE1 = "-?[0-9]*/[0-9]*[1-9][0-9]*"; // -5/3
    private static String validRegDOUBLE2 = "[0-9/.-]*";
    

    上から順に、整数として解釈すべき文字列は、マイナス記号 – があれば、一番最初にあり、後は 0 から 9 までの数字である。正規表現で書くと validRegINTEGER = “-?[0-9]*” である(正確には、- が 0 個か 1 個あり(? の意味)、それに続き、0 から 9 までの1桁の数字([ – ]の意味)が 0 個以上ある(* の意味))。非負整数として解釈すべき文字列は、0 から 9 までの数字からなり、正規表現では validRegNONNEGATIVE = “[0-9]*” である(正確には、0 から 9 までの1桁の数字が 0 個以上ある)。正整数として解釈すべき文字列は、非負整数の中で、途中に 0 ではない 1 から 9 までの数字を 1 桁含むことであり、正規表現では validRegPOSITIVE = “[0-9]*[1-9][0-9]*” である。Double として解釈すべき文字列は、(1)整数や小数点を含む小数、の場合の正規表現は、validRegDOUBLE0 = “-?[0-9]*[.]?[0-9]*” である。(2)「/」を含み、それよりも前は整数値である分子、それより後は正整数値としての分母、の場合の正規表現は、validRegDOUBLE1 = “-?[0-9]*/[0-9]*[1-9][0-9]*”; である。

    以上は、それぞれのタイプとして解釈されるべき、最終的な入力結果の正規表現である。入力途中の文字列、編集途中の文字列も、それが妥当であれば、「一致する」という答えを出す正規表現も列挙する必要がある。整数と非負整数の場合は、入力途中と編集途中に、上述の正規表現以外に必要なものはない(整数と非負整数の場合、getValue() で例外が発生することはない。)。しかし、正整数と Double の場合は、入力途中や編集途中に上述とは異なる正規表現が必要になる。

    正整数の場合、正規表現 validRegPOSITIVE = “[0-9]*[1-9][0-9]*” では、必ず、1桁の正の整数を含む必要があるので、入力途中や編集途中に、空白や 0 だけの数字列にしようとすると、拒否される。既定値の 1 が選択されている状態で、例えば、5 を入力するために、Delete キーを押して、次に、5 を入力しようとすると、最初の Delete キーを押しても選択されている 1 は削除されない。しかし、次の、5 の入力で置き換え入力されて、目的の 5 になる。また、108 を 102 に編集するために、最初の 1 を削除し、最後の 8 を削除し、・・・、と試みると、08 を 0 にするところで、拒否される。しかし、入力途中や編集途中に、空白や 0 だけの文字列に出来ないことが、ユーザーを不快にする、ということはないと判断するので、これに対処することは止める(正整数の場合も getValue() で例外が発生することはない。)

    Double の場合は、状況が異なる。上述の(1)の場合は正規表現 validRegDOUBLE0 = “-?[0-9]*[.]?[0-9]*” で入力途中も編集途中も問題ないが、(2)の場合は正規表現 validRegDOUBLE1 = “-?[0-9]*/[0-9]*[1-9][0-9]*” が「/ とそのあとに 0 ではない 1 から 9 までの数字を 1 桁含む」なので、例えば、「1/2」を目指して、左の方から入力し始めると、途中で拒否されてそれ以上進めなくなる。これは致命的な状況なので、入力可能な文字の 0 回以上の繰り返しの正規表現である validRegDOUBLE2 = “[0-9/.-]*” を加えた(- を「何々から何々まで」を表すのではなく、「-」そのものを表すようにする時は、brackets [] 内の最初か最後に置く)。ただし、これを加えたために、入力途中や編集途中に不適切な文字列が現れるので、なるべくその場合を防ぐことが必要になり、また、getValue() で例外が発生する、ことになる(ただし、(1)の正規表現 validRegDOUBLE0 = “-?[0-9]*[.]?[0-9]*” でも、文字列 “.” は一致し、例外が発生する)。

    textFormatter に設定する numberFieldFilter は次の通りである。return change; と受け入れれば表示される文字列を newText に代入する。type が Double 以外は簡単で、対応する正規表現を validReg に入れ、newText が validReg に一致するかチェックし、一致すれば return change; と受け入れ、そうでなければ return null; と拒否する。

    type が Double の場合は、少し複雑である。まず、validRegDOUBLE0 = “-?[0-9]*[.]?[0-9]*” や validRegDOUBLE1 = “-?[0-9]*/[0-9]*[1-9][0-9]*” と一致する場合は、Double の妥当な表現なので受け入れる。両方に一致しない場合は、入力途中や編集途中であるので、validRegDOUBLE2 = “[0-9/.-]*” に一致しない場合、不適切な文字列なので拒否(return null;)する。validRegDOUBLE2 = “[0-9/.-]*” に一致した場合でも、- が最初の文字でない場合、. が2個以上ある場合、/ が2個以上ある場合、. と / が両方ある場合、不適切な文字列なので、拒否をする。それ以外は受け入れ、後は、getValue() に任す。

    private UnaryOperator numberFieldFilter = new UnaryOperator() {
    
    	@Override
    	public Change apply(Change change) {
    		String newText = change.getControlNewText();
    		String validReg = null;
    		boolean isValid = false;
    		if (type != DOUBLE) {
    			switch (type) {
    			case INTEGER:
    				validReg = validRegINTEGER;
    				break;
    			case NONNEGATIVE_INTEGER:
    				validReg = validRegNONNEGATIVE;
    				break;
    			case POSITIVE_INTEGER:
    				validReg = validRegPOSITIVE;
    				break;
    			}
    			isValid = newText.matches(validReg);
    			if (isValid) {
    				return change;
    			} else {
    				return null;
    			}
    		} else { // type == DOUBLE
    			validReg = validRegDOUBLE0;
    			isValid = newText.matches(validReg);
    			if (isValid) return change;
    			validReg = validRegDOUBLE1;
    			isValid = newText.matches(validReg);
    			if (isValid) return change;
    			validReg = validRegDOUBLE2;
    			isValid = newText.matches(validReg);
    			if (!isValid) return null;
    			if (newText.indexOf('-') != -1 && newText.lastIndexOf('-') > 0) return null; // minus must be in the first column
    			if (newText.indexOf('.') != -1 && newText.indexOf('.') != newText.lastIndexOf('.')) return null; // more than one period are not valid
    			if (newText.indexOf('/') != -1 && newText.indexOf('/') != newText.lastIndexOf('/')) return null; // more than one slash are not valid
    			if (newText.indexOf('/') >0 && newText.indexOf('.') > 0) return null; // both of slash and period are not valid
    			return change;
    		}
    	}
    	
    };
    

    以上で、Filter の説明を終了した。

    最後に、正規表現の “.” と “[.]” を比較する。これらに一致するかどうかをチェックする文字列を “a” または “.” とする。結果は次の通りである。

    文字列 “a” と 正規表現 “.”
    RegExpression 1
    文字列 “a” と 正規表現 “[.]”
    RegExpression 2
    文字列 “.” と 正規表現 “[.]”
    RegExpression 3

    すなわち、正規表現の “.” の方は任意の1文字を意味し、”[.]” の方は1文字 “.” を意味する。なお、正規表現 “\.” (\ は 次の文字の特別な意味、任意の位置文字、を無効にする)は “[.]” と同じ意味を持つ。

    Eclipse プロジェクト NumberFormatterFilter の src の zip ファイルはこちら。実行ファイルはこちら