序文
ゴシップ
前回の記事「デスクトップの練習用 Compose: Compose-jb をタイム ウォーターマーク アシスタントとして使用する」では、Compose で TextField の入力コンテンツをフィルタリングする方法について穴を埋めました。数か月後、今日の記事はこの穴を埋めるものです。
なぜフィルタリングを追加する必要があるのですか
正式に始める前に、まずタイトルの質問に答えましょう。なぜフィルタリングが必要なのでしょうか?
ご存知のとおり、Android のネイティブ View システムでは、入力ボックスは EditText です。xml レイアウト ファイルにinputType
次の属性を追加することで、いくつかのプリセット可能な入力コンテンツ形式を指定できます。 number
、numberDecimal
、numberSigned
およびその他の属性は、それぞれフィルタリングされたことを示します。入力結果は数値、10 進数、および符号付き数値のみが許可されます。さらに、 EditText から継承したInputFilter
フィルタリング メソッド ( ) をカスタマイズすることで、setFilters
独自のフィルタリング ルールをカスタマイズすることもできます。
しかし、Compose ではどうすればよいでしょうか?
Compose では、入力ボックスは ですTextField
。パラメータ リストを検索するとTextField
、EditText のようなフィルタリング パラメータが提供されていないことがわかります。
最大で1 つのkeyboardOptions: KeyboardOptions = KeyboardOptions.Default
パラメーターのみが見つかります。このパラメーターは、テンキーのみを表示するなど、必要な入力タイプを表示するための入力メソッドに対する要求にすぎません。ただし、これは単なる要求であり、要件ではないため、入力方法は必ずしも合理的ではありません、ははは。このパラメータは入力ボックスの内容を制限するものではなく、デフォルトでポップアップするソフト キーボードの種類を変更するだけです。
実際、考えてみれば理解するのは難しくありませんが、結局のところ、Compose は宣言型 UI であり、入力されたコンテンツをどのように処理するかは自分で実装する必要があります。
しかし、公式が EditText と同様のプリセット フィルター パラメーターをいくつか提供できれば素晴らしいのですが、残念ながらそうではありません。
したがって、入力コンテンツのフィルタリングを自分で実装する必要があります。
タイトルの内容に戻りますが、なぜ入力コンテンツをフィルタリングする必要があるのでしょうか?
ここでは、例として上記の時間透かしアシスタントを取り上げます。
このインターフェイスでは、複数のパラメータを入力する必要があります。
「エクスポート画質」パラメータについては、入力内容を 0.0 ~ 1.0 の浮動小数点数に制限する必要があります。
もちろん、入力を全く制限せず、ユーザーが任意に入力し、実際にデータを送信する際に検証を行うことはできますが、これでは明らかに無理があり、ユーザーエクスペリエンスは良くありません。
したがって、ユーザーが入力したときに直接制限できる方がよいでしょう。
達成
まずは試してみてください
実際、フィルタリングを実行することは不可能ではありません。よく考えると、非常に簡単なように思えます。ここでは、入力コンテンツの長さを単純に制限する例を示します (EditTExt の maxLength 属性と同様):
var inputValue by remember {
mutableStateOf("") }
TextField(
value = inputValue,
onValueChange = {
inputValue = it }
)
おそらく読者は、「入力長を制限するためだけではないのですか? これは宣言型 UI では問題ではありません。これを実行しましょう。
val maxLength = 8
var inputValue by remember {
mutableStateOf("") }
TextField(
value = inputValue,
onValueChange = {
if (it.length <= maxLength) inputValue = it
}
)
入力値が変化した場合の判定を追加し、入力値の長さが定義された最大長未満の場合にのみ値を変更しますinputValue
。
一見、問題ないと思いますよね?
しかし、もう一度考えてください。
本当にそんな簡単なことなのでしょうか?
次の 2 つの状況について考えたことはありますか。
- 8文字入力したら、カーソルを真ん中の位置に移動し、この時点で内容を入力するとどうなるでしょうか?
- 8 文字未満 (たとえば 5 文字以降) を入力し、同時に制限された文字数 (たとえば 4 文字) を超える文字を貼り付けた場合、何が起こると思いますか?
これはもう秘密ではありませんが、実際、ケース 1 では、コンテンツは確かに追加されないものの、カーソルが後方に移動する状況が発生します。
2.の状況については、私が言わなくても読者の皆さんは想像できると思いますが、貼り付けても反応がありません。
そうなんです、明らかに でonValueChange
判定を加えているので、現在の入力値(it
)が制限値( )より大きい場合maxLength
は何も応答しません。しかし、これは明らかに不合理です。入力ボックスに直接貼り付けるすべてのコンテンツが確かに最大文字数制限を超えることになりますが、入力ボックスにコンテンツを入力できなくなるわけではありません。明らかに、入力ボックスは別の入力も受け入れることができます。 3文字の。したがって、新しく入力したコンテンツを切り捨て、必要な量を満たすコンテンツを入力ボックスに挿入し、余分なコンテンツを直接破棄する必要があります。
ネイティブ View の EditText にもそのような処理ロジックがあります。
それでは、今何をすべきでしょうか?
練習して、入力文字数を制限してください
上記の簡単なテストの後、入力内容を制限するには、入力された文字列を直接処理するだけではなく、より多くの状況を考慮する必要があることは誰もが知っていると思います。入力ボックス内のカーソルの制御。2 つ目は、複数の文字を選択して貼り付ける処理です (通常の入力では、一度に 1 文字のみが確実に入力 (または削除) されるためですが、貼り付けまたは複数選択した後は必ずしもそうであるとは限りません) )。
value
明らかに、カーソルを制御したい場合は、それを文字列として直接使用することはできませんがTextField
、代わりに次のように使用する必要がありますTextFieldValue
。
var inputValue by remember {
mutableStateOf(TextFieldValue()) }
OutlinedTextField(
value = inputValue,
onValueChange = {
}
)
TextFieldValue
は、入力コンテンツ ( text: String
)、選択およびカーソル状態 ( ) をカプセル化するselection: TextRange
クラスです。
TextRange
パラメータは2つあり、start
それぞれend
選択されたテキストの開始位置と終了位置を表し、2つの値が等しい場合はテキストが選択されていないことを意味し、このときのカーソル位置を表しますTextRange
。
さて、上記の 2 つの問題を解決できる前提条件はすでに揃っています。この問題を解決する方法は次のとおりです。
実際、問題 1 については、非常に簡単に解決できます。コードを大幅に変更する必要はなく、使用する String 値を変更するだけですTextFieldValue
。
val maxLength = 8
var inputValue by remember {
mutableStateOf(TextFieldValue()) }
OutlinedTextField(
value = inputValue,
onValueChange = {
if (it.text.length <= maxLength) inputValue = it
}
)
理由も非常に単純で、すでにカーソル情報が含まれているため、ここでは入力内容が制限長を超えた場合に値をTextFieldValue
変更しません。実際にはカーソル情報と一緒に変更されず、上記は String を直接使用しています。inputValue
入力内容は変更されませんが、カーソル位置は変更されます。
質問 2 については、特別な処理を行う必要があります。
まず、入力への変更を処理する関数を定義します。
fun filterMaxLength(
inputTextField: TextFieldValue,
lastTextField: TextFieldValue,
maxLength: Int
): TextFieldValue {
// TODO
}
この関数は 2 つのパラメータを受け取ります: inputTextField
、lastTextField
それぞれ新しい入力コンテンツの追加後TextFieldValue
と新しいコンテンツが入力されていないときを表しますTextFieldValue
。
ここで注意すべき点が1つあります。それは、 のコールバックでTextField
、onChange
を使用するとTextFieldValue
、入力内容が変化したときにコールバックが呼び出されるだけでなくonChange
、カーソルが移動したり状態が変化しただけでもコールバックが呼び出されますonChange
。
次に、この関数で複数の文字を貼り付けるときの状況を処理します。
val inputCharCount = inputTextField.text.length - lastTextField.text.length
if (inputCharCount > 1) {
// 同时粘贴了多个字符内容
val allowCount = maxLength - lastTextField.text.length
// 允许再输入字符已经为空,则直接返回原数据
if (allowCount <= 0) return lastTextField
// 还有允许输入的字符,则将其截断后插入
val newString = StringBuffer()
newString.append(lastTextField.text)
val newChar = inputTextField.text.substring(lastTextField.selection.start..allowCount)
newString.insert(lastTextField.selection.start, newChar)
return lastTextField.copy(text = newString.toString(), selection = TextRange(lastTextField.selection.start + newChar.length))
}
このコードは実は分かりやすく、まず更新前の文字長を使って今回入力した文字長を引いて実際の新しい文字長を求めます、その長さが1より大きい場合は複数の内容が貼り付けられているとみなします同時に入力ボックスに入力します。(ここで注意すべき点があります。つまり、この方法で取得した値が 0 になる可能性があります。これは、カーソルのみが変化し、文字は変化しないことを意味します。0 未満の場合は、内容は削除されます。)
次に、最大許容入力文字長から更新前の入力ボックスの文字長を引いた値を使用して、現在さらに何文字挿入できるかを取得しますallowCount
。
入力可能な文字がまだある場合は、入力内容の文字を長さに合った新しいフィールドにインターセプトして取得します。
インターセプトの開始点は更新前のカーソルの初期位置 ( lastTextField.selection.start
) であり、インターセプト長はまだ入力可能な文字長です。
なお、lastTextField.selection.start
ここで傍受の開始点としてlastTextField.selection.end
使用しているのは、貼り付けて挿入する際に、事前に何らかのコンテンツを選択してから挿入しているためではなく、更新されていないときの選択状態を挿入位置として使用する必要があります。また、貼り付け時や挿入時に選択状態でない場合は、この時点では値が同じなので両方start
を使用できます。end
挿入できる文字を取得したら、次の手順で文字を挿入しますnewString.insert(lastTextField.selection.start, newChar)
。
最後に戻るときにカーソルの位置を変更することを忘れないでください。実際は非常に簡単で、新しい文字が挿入される位置 + 実際に挿入される文字数に変更するだけですTextRange(lastTextField.selection.start + newChar.length)
。
最後に、入力長を制限する完全なフィルター関数は次のとおりです。
/**
* 过滤输入内容长度
*
* @param maxLength 允许输入长度,如果 小于 0 则不做过滤,直接返回原数据
* */
fun filterMaxLength(
inputTextField: TextFieldValue,
lastTextField: TextFieldValue,
maxLength: Int
): TextFieldValue {
if (maxLength < 0) return inputTextField // 错误的长度,不处理直接返回
if (inputTextField.text.length <= maxLength) return inputTextField // 总计输入内容没有超出长度限制
// 输入内容超出了长度限制
// 这里要分两种情况:
// 1. 直接输入的,则返回原数据即可
// 2. 粘贴后会导致长度超出,此时可能还可以输入部分字符,所以需要判断后截断输入
val inputCharCount = inputTextField.text.length - lastTextField.text.length
if (inputCharCount > 1) {
// 同时粘贴了多个字符内容
val allowCount = maxLength - lastTextField.text.length
// 允许再输入字符已经为空,则直接返回原数据
if (allowCount <= 0) return lastTextField
// 还有允许输入的字符,则将其截断后插入
val newString = StringBuffer()
newString.append(lastTextField.text)
val newChar = inputTextField.text.substring(lastTextField.selection.start..allowCount)
newString.insert(lastTextField.selection.start, newChar)
return lastTextField.copy(text = newString.toString(), selection = TextRange(lastTextField.selection.start + newChar.length))
}
else {
// 正常输入
return if (inputTextField.selection.collapsed) {
// 如果当前不是选中状态,则使用上次输入的光标位置,如果使用本次的位置,光标位置会 +1
lastTextField
} else {
// 如果当前是选中状态,则使用当前的光标位置
lastTextField.copy(selection = inputTextField.selection)
}
}
}
実はここのフィルター機能にはまだ問題があるのですが、読者の皆さんは発見されたかどうかわかりません。ここでは指摘したり変更したりしません。それは読者が考えるべき問題です。結局のところ、読者にはコードを読んで貼り付けるだけでほしくないのです。はははは。
使用するときのコールバックTextField
を変更するだけで済みますonChange
。
val maxLength = 8
var inputValue by remember {
mutableStateOf(TextFieldValue()) }
OutlinedTextField(
value = inputValue,
onValueChange = {
inputValue = filterMaxLength(it, inputValue, maxLength)
}
)
それを展開して一般的なフィルターを実行します
上記で入力コンテンツの制限を独自に行うことはできましたが、拡張性はあまり良くないようです。
汎用的で拡張しやすいフィルタリング方法を作成する方法はあるでしょうか?
結局のところ、View でのフィルタリングは、多くの便利なフィルタをプリセットするだけでなく、InputFilter
必要なフィルタリング メソッドを定義できる一般的なインターフェイスも提供します。
それでは、やってみましょう。まず、 View でInputFilter
どのように記述されているかを見てみましょう。
こうして見ると、実際にはそれほど複雑ではなく、メソッドがInputFilter
1 つだけあるクラスでありfilter
、このメソッドはCharSequence
フィルタリング後の新しい文字を表す を返します。
6 つのパラメータが提供されます。
source
: 挿入する新しい文字start
:source
挿入する文字位置の開始点end
:source
挿入する文字位置の終点dest
: 入力ボックスの元のコンテンツdstart
:dest
挿入するsource
位置の開始点dend
:dest
挿入するsource
位置の終点
これら 6 つのパラメータを使用するだけで、文字をフィルタリングして新しい文字を返すことができます。
しかし、in View はInputFilter
明らかに文字のフィルタリングのみを担当し、カーソルの位置を変更することは担当しません。
とにかく、フィルター基本クラスの Compose バージョンも作成しましょうBaseFieldFilter
。
open class BaseFieldFilter {
private var inputValue = mutableStateOf(TextFieldValue())
protected open fun onFilter(inputTextFieldValue: TextFieldValue, lastTextFieldValue: TextFieldValue): TextFieldValue {
return TextFieldValue()
}
protected open fun computePos(): Int {
// TODO
return 0
}
protecte fun getNewTextRange(
lastTextFiled: TextFieldValue,
inputTextFieldValue: TextFieldValue
): TextRange? {
// TODO
retutn null
}
protecte fun getNewText(
lastTextFiled: TextFieldValue,
inputTextFieldValue: TextFieldValue
): TextRange? {
// TODO
return null
}
fun getInputValue(): TextFieldValue {
return inputValue.value
}
fun onValueChange(): (TextFieldValue) -> Unit {
return {
inputValue.value = onFilter(it, inputValue.value)
}
}
}
onFilter
このカテゴリではメソッドに焦点を当てる必要があり、フィルタリングのコンテンツは主にこのメソッドで記述されています。
次に、 とメソッドはTextField
主に で使用されます。getInputValue
onValueChange
当初は、実際に挿入された新しい文字、実際に挿入された文字の位置、および新しいインデックス位置を計算するために使用されるいくつかの基本的なツール メソッドgetNewText
、を作成する予定もありました。getNewTextRange
computePos
しかし、後から気づいたのですが、使いやすい一般的なメソッドを書くのは簡単ではないようなので、ここでは空白にしておきます。
この基本クラスも使い方が非常に簡単です。必要なのは、この基本クラスから独自のフィルタリング メソッドを継承し、メソッドをオーバーロードすることだけです。onFilter
例として入力長を制限してクラスを作成してみましょうFilterMaxLength
。
/**
* 过滤输入内容长度
*
* @param maxLength 允许输入长度,如果 小于 0 则不做过滤,直接返回原数据
* */
class FilterMaxLength(
@androidx.annotation.IntRange(from = 0L)
private val maxLength: Int
) : BaseFieldFilter() {
override fun onFilter(
inputTextFieldValue: TextFieldValue,
lastTextFieldValue: TextFieldValue
): TextFieldValue {
return filterMaxLength(inputTextFieldValue, lastTextFieldValue, maxLength)
}
private fun filterMaxLength(
inputTextField: TextFieldValue,
lastTextField: TextFieldValue,
maxLength: Int
): TextFieldValue {
if (maxLength < 0) return inputTextField // 错误的长度,不处理直接返回
if (inputTextField.text.length <= maxLength) return inputTextField // 总计输入内容没有超出长度限制
// 输入内容超出了长度限制
// 这里要分两种情况:
// 1. 直接输入的,则返回原数据即可
// 2. 粘贴后会导致长度超出,此时可能还可以输入部分字符,所以需要判断后截断输入
val inputCharCount = inputTextField.text.length - lastTextField.text.length
if (inputCharCount > 1) {
// 同时粘贴了多个字符内容
val allowCount = maxLength - lastTextField.text.length
// 允许再输入字符已经为空,则直接返回原数据
if (allowCount <= 0) return lastTextField
// 还有允许输入的字符,则将其截断后插入
val newString = StringBuffer()
newString.append(lastTextField.text)
val newChar = inputTextField.text.substring(lastTextField.selection.start..allowCount)
newString.insert(lastTextField.selection.start, newChar)
return lastTextField.copy(text = newString.toString(), selection = TextRange(lastTextField.selection.start + newChar.length))
}
else {
// 正常输入
return if (inputTextField.selection.collapsed) {
// 如果当前不是选中状态,则使用上次输入的光标位置,如果使用本次的位置,光标位置会 +1
lastTextField
} else {
// 如果当前是选中状态,则使用当前的光标位置
lastTextField.copy(selection = inputTextField.selection)
}
}
}
}
この時点で、TextField
次のように呼び出す必要があります。
val filter = remember {
FilterMaxLength(8) }
OutlinedTextField(
value = filter.getInputValue(),
onValueChange = filter.onValueChange(),
)
どうでしょう、とても便利で早いと思いませんか?
私がもっと欲しい!
もちろん、上記は常に入力長を制限する例でしたが、他のフィルタリング実装ではどうなるでしょうか? 心配しないでください。私のプロジェクトで使用したフィルタリング方法をいくつか紹介します。
同様に、これらの方法には多かれ少なかれ落とし穴があるので、確認せずに使用しないでください(ニヤリ)。
あはは、冗談ですが、実は、序文で述べたプロジェクトには、落とし穴のない完全なコードが見つかります。
フィルタ番号
class FilterNumber(
private val minValue: Double = -Double.MAX_VALUE,
private val maxValue: Double = Double.MAX_VALUE,
private val decimalNumber: Int = -1
) : BaseFieldFilter() {
override fun onFilter(
inputTextFieldValue: TextFieldValue,
lastTextFieldValue: TextFieldValue
): TextFieldValue {
return filterInputNumber(inputTextFieldValue, lastTextFieldValue, minValue, maxValue, decimalNumber)
}
private fun filterInputNumber(
inputTextFieldValue: TextFieldValue,
lastInputTextFieldValue: TextFieldValue,
minValue: Double = -Double.MAX_VALUE,
maxValue: Double = Double.MAX_VALUE,
decimalNumber: Int = -1,
): TextFieldValue {
val inputString = inputTextFieldValue.text
val lastString = lastInputTextFieldValue.text
val newString = StringBuffer()
val supportNegative = minValue < 0
var dotIndex = -1
var isNegative = false
if (supportNegative && inputString.isNotEmpty() && inputString.first() == '-') {
isNegative = true
newString.append('-')
}
for (c in inputString) {
when (c) {
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9' -> {
newString.append(c)
val tempValue = newString.toString().toDouble()
if (tempValue > maxValue) newString.deleteCharAt(newString.lastIndex)
if (tempValue < minValue) newString.deleteCharAt(newString.lastIndex) // TODO 需要改进 (例如限制最小值为 100000000,则将无法输入东西)
if (dotIndex != -1) {
if (decimalNumber != -1) {
val decimalCount = (newString.length - dotIndex - 1).coerceAtLeast(0)
if (decimalCount > decimalNumber) newString.deleteCharAt(newString.lastIndex)
}
}
}
'.' -> {
if (decimalNumber != 0) {
if (dotIndex == -1) {
if (newString.isEmpty()) {
if (abs(minValue) < 1) {
newString.append("0.")
dotIndex = newString.lastIndex
}
} else {
newString.append(c)
dotIndex = newString.lastIndex
}
if (newString.isNotEmpty() && newString.toString().toDouble() == maxValue) {
dotIndex = -1
newString.deleteCharAt(newString.lastIndex)
}
}
}
}
}
}
val textRange: TextRange
if (inputTextFieldValue.selection.collapsed) {
// 表示的是光标范围
if (inputTextFieldValue.selection.end != inputTextFieldValue.text.length) {
// 光标没有指向末尾
var newPosition = inputTextFieldValue.selection.end + (newString.length - inputString.length)
if (newPosition < 0) {
newPosition = inputTextFieldValue.selection.end
}
textRange = TextRange(newPosition)
}
else {
// 光标指向了末尾
textRange = TextRange(newString.length)
}
}
else {
textRange = TextRange(newString.length)
}
return lastInputTextFieldValue.copy(
text = newString.toString(),
selection = textRange
)
}
}
指定された文字のみを許可する
class FilterOnlyChar() : BaseFieldFilter() {
private var allowSet: Set<Char> = emptySet()
constructor(allowSet: String) : this() {
val tempSet = mutableSetOf<Char>()
for (c in allowSet) {
tempSet.add(c)
}
this.allowSet = tempSet
}
constructor(allowSet: Set<Char>) : this() {
this.allowSet = allowSet
}
override fun onFilter(
inputTextFieldValue: TextFieldValue,
lastTextFieldValue: TextFieldValue
): TextFieldValue {
return filterOnlyChar(
inputTextFieldValue,
lastTextFieldValue,
allowChar = allowSet
)
}
private fun filterOnlyChar(
inputTextFiled: TextFieldValue,
lastTextFiled: TextFieldValue,
allowChar: Set<Char>
): TextFieldValue {
if (allowChar.isEmpty()) return inputTextFiled // 如果允许列表为空则不过滤
val newString = StringBuilder()
var modifierEnd = 0
for (c in inputTextFiled.text) {
if (c in allowChar) {
newString.append(c)
}
else modifierEnd--
}
return inputTextFiled.copy(text = newString.toString())
}
}
メールアドレスをフィルタリングする
class FilterStandardEmail(private val extraChar: String = "") : BaseFieldFilter() {
private val allowChar: MutableSet<Char> = mutableSetOf('@', '.', '_', '-').apply {
addAll('0'..'9')
addAll('a'..'z')
addAll('A'..'Z')
addAll(extraChar.asIterable())
}
override fun onFilter(
inputTextFieldValue: TextFieldValue,
lastTextFieldValue: TextFieldValue
): TextFieldValue {
return inputTextFieldValue.copy(text = filterStandardEmail(inputTextFieldValue.text, lastTextFieldValue.text))
}
private fun filterStandardEmail(
inputString: String,
lastString: String,
): String {
val newString = StringBuffer()
var flag = 0 // 0 -> None 1 -> "@" 2 -> "."
for (c in inputString) {
if (c !in allowChar) continue
when (c) {
'@' -> {
if (flag == 0) {
if (newString.isNotEmpty() && newString.last() != '.') {
if (newString.isNotEmpty()) {
newString.append(c)
flag++
}
}
}
}
'.' -> {
// if (flag >= 1) {
if (newString.isNotEmpty() && newString.last() != '@' && newString.last() != '.') {
newString.append(c)
// flag++
}
// }
}
else -> {
newString.append(c)
}
}
}
return newString.toString()
}
}
フィルター 16 進カラー
class FilterColorHex(
private val includeAlpha: Boolean = true
) : BaseFieldFilter() {
override fun onFilter(
inputTextFieldValue: TextFieldValue,
lastTextFieldValue: TextFieldValue
): TextFieldValue {
return inputTextFieldValue.copy(filterInputColorHex(
inputTextFieldValue.text,
lastTextFieldValue.text,
includeAlpha
))
}
private fun filterInputColorHex(
inputValue: String,
lastValue: String,
includeAlpha: Boolean = true
): String {
val maxIndex = if (includeAlpha) 8 else 6
val newString = StringBuffer()
var index = 0
for (c in inputValue) {
if (index > maxIndex) break
if (index == 0) {
if (c == '#') {
newString.append(c)
index++
}
}
else {
if (c in '0'..'9' || c.uppercase() in "A".."F" ) {
newString.append(c.uppercase())
index++
}
}
}
return newString.toString()
}
}
要約する
Compose は、EditText の inputType に似たプリセットの入力コンテンツ フィルタリングを正式に提供していませんが、Compose の宣言型 UI のおかげで、入力コンテンツを直接操作できるため、面倒な手順をあまり行わずに Compose で入力コンテンツをフィルタリングすることが簡単になります。
この記事では、Android のネイティブ View の inputType と同様に、Compose で入力コンテンツをフィルタリングする方法を簡単に実装する方法を浅いものから深いものまで紹介し、誰もが使用できるいくつかの一般的なフィルターを提供します。