書式化したまま編集できるテキストフィールドを作る(JavaFX)

先日 インターネットバンキングで振込操作をしたのですが ユーザーインターフェースの出来があまり良くないと感じました。振込金額の欄に 3 桁区切りのカンマが表示されず 100 万円を振り込むために 1000000 と入力する必要があったのです。

 いち じゅう ひゃく せん まん

画面の数字を 1 桁ずつ指差しながら数えて金額に間違いがないことを確認しました。一応 確認画面に進むとカンマ付きで 1,000,000円 と表示されたのですが 画面遷移するまで書式化された表示にならないのってユーザビリティとしてどうなの?

他所様のシステムに文句を言いたいわけじゃないんです。自分も同じような実装をしてきたなあと反省してしまったのです。画面遷移するまで書式化されないというほど酷くはなかったのですが…

私も こんなユーザーインターフェースを当たり前のように作ってきました。

なぜフォーカスした時に書式化を解除するんでしょうね?

そのほうが入力しやすいからでしょうか? うーん… そうは思えません。フォーカスが外れた時にカンマ付きの書式にしているのは桁を認識しやすくするためですよね。それなら フォーカスした時もカンマ付きで桁を認識できたほうが入力しやすいと思いませんか?

フォーカスした時に書式化を解除するインターフェースというのは ユーザビリティよりも実装の容易さ すなわち コンピューターの都合を優先した結果ではないかと思います。妥協せずに使い勝手を追求するなら 書式化解除ではなく書式化したまま入力がベストという答えにたどり着くのかもしれません。

書式化したまま編集できるテキストフィールドを作る

というわけで作ってみましょう。書式化したまま編集できるテキストフィールドを。

こんな感じのテキストフィールドを JavaFX で実現します。普通の文字入力用のテキストフィールドのようにも見えますが 文字入力用ではございません。あくまでも数字入力用のテキストフィールドとして以下の仕様を盛り込みます。

  •  入力できるのは非負整数 ゼロと正の整数 とします。マイナスの値は入力できません。テキストフィールドが空の状態も許容します。

  •  入力可能な桁数を指定できます。桁数にはカンマを含みません。

  •  ユーザーが入力できるのは数字のみです。カンマを含め他の文字は入力できません。

  •  クリップボードから貼り付け操作をした場合は 数字以外の文字を除いて数字部分のみが貼り付けられます。たとえば 100円 を貼り付けようとした場合 数字のみを抽出した 100 が入力されます。ただし . を含む場合は . 以降の数字も取り除きます。12.34 という小数表現の文字列を貼り付けた場合に 1234 ではなく 12 が入力されるほうが望ましいと考えたからです。

  •  カンマは常に適切な位置に自動的に表示されます。1,000 の先頭文字 1 を削除して ,000 という表示になってしまったり 末尾の 0 を削除して 1,00 という表示になってしまうことがないようにします。

  •  カーソル移動や範囲選択ではカンマを適切にスキップします。たとえば 左カーソルキーを 4 回押したならキャレットは数字 4 桁分移動します。カンマを飛び越えるのにカーソルキーを余分に押さなくていいようにします。

TextFormatter

JavaFX には TextFormatter という便利なクラスがあります。このクラス名から 数字や日付などの値と書式化された文字列表現を変換するものを想像しがちですが TextFormatter クラスの役割はそれだけではありません。TextFormatter クラスには もう 1 入力フィルターとして役割があります。入力フィルターを使うことでユーザー入力に割り込んで内容を無視したり変更したりできるようになります。

TextFormatter 値と文字列表現の相互変換を担当する StringConverter クラスと 入力制御 フィルター を担当する TextFormatter.Change クラスの 2 つから構成されます。

UnaryOperator<TextFormatter.Change>

今回は StringConverter のことは横に置いておき TextFormatter.Change を使って入力制御を実現していきます。具体的には TextFormatter.Change インスタンスを変化させる関数インターフェース UnaryOperator<TextFormatter.Change>の実装クラスを作ることになります。

UnaryOperator
unary 単項の という意味を持つ形容詞で unary operator 単項演算子 になります。読み方は ユーナリー ユーナリー オペレーター です。unary の語源が uni binary ユニ バイナリー であることを覚えておけば ユーナリー という読み方も思い出せるはず。un array アン アレイ と読んでしまわないように気を付けてくださいね。
入力フィルターの雛形
import java.util.function.UnaryOperator; import javafx.scene.control.TextFormatter.Change; public class MyFilter implements UnaryOperator<Change> { @Override public Change apply(Change t) { return t; } }

入力フィルターの雛形はこんな感じになります。

この入力フィルター MyFilter TextFormatter のコンストラクタ引数に指定すれば MyFilter によって入力が制御される TextFormatter インスタンスができます。

TextFormatter<String> myFormatter = new TextFormatter<String>(new MyFilter());

こんな感じで myFormatter を作り それを TextField インスタンスに設定します。

TextField myTextField = ...;
myTextField.setTextFormatter(myFormatter);

これで テキストフィールドに独自の入力フィルター MyFilter が設定されました。テキストフィールドに入力操作をおこなうたびに MyFilter クラスの apply(Change t) メソッドが呼び出されます。

TextFormatter.Change

UnaryOperator<TextFormatter.Change>インターフェースの実装クラスを作り apply(Change t) メソッドがコールバックされるようになりました。

次は コールバックされた apply(Change t) メソッドで入力制御する方法を説明しましょう。apply メソッドの引数である TextFormatter.Change コントロールの現在のテキスト状態と ユーザー入力によってこれから確定されようとしているテキスト状態を保持しています。

何もせずに引数をそのまま戻り値にすれば ユーザー入力はそのまま確定します。

何も制御しない入力フィルター
public Change apply(Change t) { return t; }

戻り値を null にすると ユーザー入力は破棄されコントロールのテキストは変化しません。たとえば 小文字の a を入力できないようにするフィルターは以下のようになります。

小文字の a を入力できないフィルター
public Change apply(Change t) { if("a".equals(t.getText())) { // 入力テキストが a だったら null を返す。 return null; } return t; }

TextFormatter.Change を変化させることもできます。たとえば 小文字の b を入力しようとしたら大文字の B が入力されるフィルターは以下のようになります。

小文字の b を大文字の B に変換する入力するフィルター
public Change apply(Change t) { if("b".equals(t.getText())) { // 入力テキストが b だったら B に置き換える。 t.setText("B"); } return t; }

次は数字しか入力できないフィルターです。

数字しか入力できないフィルター
public Change apply(Change t) { if(!t.getText().matches("[0-9]*")) { // 入力テキストが数字のみにマッチしなかったら return null; // null を返す。(テキスト変更を許可しない) } return t; }

マイナスの数値も入力させたい場合はどうしたら良いでしょうか?

単純に - ハイフン の入力を許容するだけでは 12-34 のようなおかしな入力ができてしまいます。このようなケースでは 入力を受け入れた場合の変更後テキストを評価するのが簡単です。

getText() に入力テキストが入っていました。TextFormatter.Change には他にも 2 つテキストを返すメソッドがあります。getControlText() getControlNewText() です。

getControlText()
コントロールの現在のテキストを返します。
getControlNewText()
変更内容を受け入れた場合の変更後のテキストを返します。

TextField の現在のテキストが 12|34 のときに - を入力するとします | はキャレット位置。すると それぞれのメソッドの返す値は以下のようになります。

  • getText() - 入力したテキスト を返します。
  • getControlText() 1234 現在のテキスト を返します。
  • getControlNewText() 12-34 変更後のテキスト を返します。

この変更後のテキストが所定の形式を満たすかどうかを評価すれば 先頭のみにマイナス符合を許容する数字入力フィルターが簡単に作れます。

マイナス符合および数字しか入力できないフィルター
@Override public Change apply(Change t) { // 変更後テキストが0~1個のハイフンで始まる数字にマッチしなかったら入力を許可しない。 if(!t.getControlNewText().matches("-?[0-9]*")) { return null; } return t; }

キャレット位置や選択範囲の変更も

TextFormatter.Change ではテキストだけでなく 変更前と変更後の選択範囲やキャレット位置なども保持しています。参照だけでなく 選択範囲やキャレット位置を変更することもできます。

入力フィルターではキーボードのカーソルキーを押したことを直接知ることはできません。キーコードを取得できるわけではないからです

ですが 変更前のキャレット位置と変更後のキャレット位置を比較することで キャレットがどの方向に移動しようとしているのかを知ることはできます。

getCaretPosition()
変更後の新しいキャレット位置を返します。
getControlCaretPosition()
コントロールの現在のキャレット位置を返します。

たとえば getCaretPosition() < getControlCaretPosition() ならキャレットは左に移動しようとしています。

isContentChange
テキストの追加や削除によってキャレット位置が変化することもあります。純粋にカーソル操作を検出する場合は isContentChange() false であることも確認してください。isContentChange() false を返す場合 テキスト自体の変更はおこなわれておらず キャレット位置や選択範囲だけが変更されます。

これを活用すれば 数字部分では 1 文字ずつキャレットを移動させ カンマの位置だけキャレットを 2 文字分移動させてスキップするなんてことも実現できます。

TextFormatter.Change の各メソッドを駆使すれば 様々な入力制限や入力補助を実現するフィルターを作ることができます。詳しくは Javadoc を参照してみてください。

書式化したまま編集できるフィルターが完成

こうして 完成したのが NonNegativeIntegerFilter.java です。入力中でも適切な位置にカンマを表示してくれる数値入力用フィルターです。

ずいぶんと長く汚いソースコードになってしまいました。キャレットや選択範囲を変更しても矛盾した状態にならないよういろいろと泥臭い実装をしてますから多少はね? 本当はもっとスマートなコードを書けたら良かったのですが 技量不足で申し訳ないです。

NonNegativeIntegerFilter.java
package com.example; import java.util.function.UnaryOperator; import javafx.scene.control.TextFormatter.Change; public class NonNegativeIntegerFilter implements UnaryOperator<Change> { private int numberOfDigits; public NonNegativeIntegerFilter() { this(9); } public NonNegativeIntegerFilter(int numberOfDigits) { this.numberOfDigits = numberOfDigits; } @Override public Change apply(Change t) { if(t.isAdded()) { StringBuilder added = new StringBuilder(t.getText()); for(int i = added.length() - 1; i >= 0; i--) { if(added.charAt(i) == '.') { added.delete(i, added.length()); } else if(added.charAt(i) < '0' || added.charAt(i) > '9') { added.deleteCharAt(i); } } if(added.length() == 0) { return null; } t.setText(added.toString()); if(getNumberOfDigits(t.getControlNewText()) > numberOfDigits) { return null; } } else if(t.isDeleted() && (t.getRangeEnd() - t.getRangeStart() == 1)) { if(t.getControlText().charAt(t.getRangeStart()) == ',') { t.setRange(t.getRangeStart(), t.getRangeEnd() + 1); } } if(t.isContentChange()) { int caretPosition = t.getCaretPosition(); StringBuilder text = new StringBuilder(t.getControlNewText()); int digitCount = 0; int digitStartIndex = 0; if(text.length() > 0 && text.charAt(0) == ',') { digitStartIndex = 1; } for(int i = text.length() - 1; i >= 0; i--) { char ch = text.charAt(i); if('0' <= ch && ch <= '9') { if(++digitCount % 3 == 0 && i > digitStartIndex) { text.insert(i, ','); if(i < caretPosition) { caretPosition++; } } } else if(ch == ',') { text.deleteCharAt(i); if(i < caretPosition) { caretPosition--; } } } t.setText(text.toString()); t.setRange(0, t.getControlText().length()); t.setAnchor(caretPosition); t.setCaretPosition(caretPosition); } else { String newText = t.getControlNewText(); if(t.getSelection().getLength() == 0) { if(t.getCaretPosition() == t.getControlCaretPosition()) { if(t.getCaretPosition() > 0 && newText.charAt(t.getCaretPosition() - 1) == ',') { t.setCaretPosition(t.getCaretPosition() - 2); } } else if(t.getCaretPosition() < t.getControlCaretPosition()) { if(t.getCaretPosition() > 0 && newText.charAt(t.getCaretPosition() - 1) == ',') { t.setCaretPosition(t.getCaretPosition() - 1); } } else if(t.getCaretPosition() > t.getControlCaretPosition()) { if(t.getCaretPosition() < newText.length() && newText.charAt(t.getControlCaretPosition()) == ',') { t.setCaretPosition(t.getCaretPosition() + 1); } } if(t.getAnchor() != t.getCaretPosition()) { t.selectRange(t.getCaretPosition(), t.getCaretPosition()); } } else { int offset = t.getCaretPosition() > t.getAnchor() ? 1 : 0; if(newText.charAt(t.getCaretPosition() - offset) == ',') { int move = t.getCaretPosition() > t.getControlCaretPosition() ? +1 : -1; t.selectRange(t.getAnchor(), t.getCaretPosition() + move); } if(t.getAnchor() < t.getCaretPosition()) { if(newText.charAt(t.getAnchor()) == ',') { t.selectRange(t.getAnchor() + 1, t.getCaretPosition()); } } else { if(t.getAnchor() > 0 && newText.charAt(t.getAnchor() - 1) == ',') { t.selectRange(t.getAnchor() - 1, t.getCaretPosition()); } } } } return t; } private static int getNumberOfDigits(CharSequence s) { int num = 0; int length = s.length(); for(int i = 0; i < length; i++) { char ch = s.charAt(i); if('0' <= ch && ch <= '9') { num++; } } return num; } }

これを使ったサンプルコード Main.java も載せておきます。

Main.java
package com.example; import javafx.application.Application; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Scene; import javafx.scene.control.Button; import javafx.scene.control.TextField; import javafx.scene.control.TextFormatter; import javafx.scene.layout.VBox; import javafx.scene.text.Font; import javafx.stage.Stage; import javafx.util.converter.NumberStringConverter; public class Main extends Application { public static void main(String[] args) { launch(args); } @Override public void start(Stage primaryStage) throws Exception { TextField textField = new TextField(); textField.setFont(Font.font("monospace", 16)); textField.setAlignment(Pos.CENTER_RIGHT); textField.setTextFormatter(new TextFormatter<Number>( new NumberStringConverter(), null, new NonNegativeIntegerFilter())); Button btnClose = new Button("close"); btnClose.setOnAction(event -> primaryStage.close()); VBox root = new VBox(10, textField, btnClose); root.setPadding(new Insets(10)); Scene scene = new Scene(root); primaryStage.setScene(scene); primaryStage.show(); } }

サンプルコードは以下のリンクからダウンロードできます。

使い方

長くて読む気も改造する気にもならないと思いますが そのまま使うこともできますので 使い方を説明していきますね。

NonNegativeIntegerFilter のコンストラクタ引数で入力可能な数字の桁数を指定します。new NonNegativeIntegerFilter(5) とすれば 5 桁までしか入力できないフィルターになります。コンストラクタ引数を省略した場合は既定で 9 桁まで入力できるフィルターになります。

このフィルターを TextField に設定する場合は以下のようにします。

TextField textField = new TextField();
textField.setAlignment(Pos.CENTER_RIGHT);
textField.setTextFormatter(new TextFormatter<String>(
		new NonNegativeIntegerFilter(7)));

これで このテキストフィールドには 7 桁の数字しか入力できなくなります。

入力した内容を取得する場合は TextField クラスの getText() メソッドを使います。

ただし getText() メソッドはカンマを含むテキスト全体を返します。上記の画像の例であれば "1,234,567" という文字列です。

せっかく 数値入力専用のフィルターを作ったのですから 入力した内容を文字列ではなく数値として取り出したいですよね?

そこで登場するのが StringConverter クラスです。様々なデータ型と文字列の相互変換をするためのコンバーターが StringConverter のサブクラスとして用意されています。今回は NumberStringConverter を使いましょう。

先程は TextFormatter の引数にフィルター NonNegativeIntegerFilter のみを指定していました。TextFormatter にはフィルターだけでなくコンバーターも指定できるコンストラクタがあります。コンバーターとして NumberStringConverter も一緒に指定する場合は以下のようになります。

TextField textField = new TextField();
textField.setAlignment(Pos.CENTER_RIGHT);
textField.setTextFormatter(new TextFormatter<Number>(
		new NumberStringConverter(), null,
		new NonNegativeIntegerFilter()));

1 引数にコンバーター NumberStringConverter 2 引数に初期値 今回は null を指定しました。第 3 引数は数値入力フィルターですね。

コンバーターを指定する場合は TextFormatter<V>の仮型引数と StringConverter<V>の仮型引数を合わせます。NumberStringConverter StringConverter<Number>を継承しているので TextFormatter<Number>でインスタンスを作成します。

NumberStringConverter コンバーターを設定すると TextFormatter を通して Number 型で入力内容を取り出せるようになります。

// カンマを含む文字列を返す。
textField.getText();

// カンマを含まず、Number型を返す。
textField.getTextFormatter().getValue();

Number は抽象クラスなので .getTextFormatter().getValue().getClass() が実際に Number を返すことはありません。今回のケースでは実行時クラスは Long になっていました。小数を扱う場合には実行時クラスが BigDecimal になることも考えられます。

フィルターで入力桁数を 9 桁以下に制限している場合は 値を int 型で取り出しても安全です。以下のようなコードが役に立つでしょう。

int value = 0;
Object v = textField.getTextFormatter().getValue();
if(v instanceof Number) {
	value = ((Number)v).intValue();
}

入力桁数を 18 桁以下に制限しているのであれば long 型で取り出しても安全です。

long value = 0;
Object v = textField.getTextFormatter().getValue();
if(v instanceof Number) {
	value = ((Number)v).longValue();
}

以上 TextFormatter を使って 書式化したまま編集できるテキストフィールドを作ってみました。JavaFX はいろいろなことが簡単にできて面白いですね 😊

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