Jetpack Composeがstableになりしばらく経ちますが、利用しているとAndroid Viewにあった様々なものが、まだ用意されていないことに気が付きます。
テキストを入力できる TextField は、まだ最大文字数( maxLength
)を設定することができません。
Issue: TextFieldValue is reset unexpectedly with multibyte charactors.
今回は最大文字数を設定するいくつかの方法について紹介を行います。
念の為、TextFieldの基本的な使い方について紹介をします。
Composaは基本的にコンポーネント内部に状態を持たせず、利用する側でStateを管理します。
TextFieldに関しても、入力中のテキストはすべて外側で管理する必要があります。
TextFieldには value
とonValueChange
のパラメータがあり、remember
した MutableState
等を更新することで実現することができます。
var value by remember { mutableStateOf(“”) }
TextField(
value = value,
onValueChange = { value = it },
label = { Text(text = "label”) }
)
アウトランデザインが適応された OutlinedTextField が用意されているのと、materialガイドラインに従わず、1からスタイルを作成する場合は BasicTextFieldを使うことができます。
最大文字数を制限するために、以下のようなコードが考えられますが、予測変換に大きな問題があります。
@Composable
fun MaxLengthTextField(
value: String,
onValueChange: (String) -> Unit,
maxLength: Int,
modifier: Modifier = Modifier,
label: @Composable (() -> Unit)? = null,
) {
TextField(
value = value,
onValueChange = {
onValueChange(it.take(maxLength))
},
modifier = modifier,
label = label
)
}
予測変換中に最大文字数を超えた場合に、入力がすべてリセットされてしまいます。
これを解消するために最大文字数に達したタイミングで文字列を確定させたり、キーボードを閉じたりする方法も考えられます。 しかし、ユーザの心象としてあまり良くないのと、変換後に文字数制限に収まるような文字列を入力できなくなる問題も発生します。
上記の課題を解決するために、予測変換中は最大文字数を超えて入力できるようにします。
TextFieldにはStringを入力するメソッドの他に、TextFieldValueを入力できるメソッドがあります。
Stringの方のメソッドも内部ではTextFieldValue
を入力するメソッドを呼んでいます。
@Composable
fun TextField(
value: String,
onValueChange: (String) -> Unit,
/* ... */
)
@Composable
fun TextField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
/* ... */
)
この TextFieldValue
には、未確定の文字範囲(TextRange)が入った composition というメンバーがあります。
@Immutable
class TextFieldValue constructor(
/* ... */
) {
/* ... */
val text: String // 入力している文字列
val composition: TextRange? // 未確定の文字範囲
/* ... */
}
これがnullかどうかチェックすることで、予測変換中かどうかを確認することができます。
少し長いですが、最終的なコードは以下のようになります。
@Composable
fun MaxLengthTextField(
value: String,
onValueChange: (String) -> Unit,
maxLength: Int,
modifier: Modifier = Modifier,
label: @Composable (() -> Unit)? = null,
) {
var textFieldValueState by remember {
mutableStateOf(TextFieldValue(text = value))
}
val currentText = textFieldValueState.text.take(maxLength)
val textFieldValue = if (value != currentText) {
textFieldValueState.copy(text = value)
} else {
textFieldValueState
}
val onTextFieldValueChange = { text: TextFieldValue ->
val isComposing = text.composition != null
val nextText = text.text.take(maxLength)
if (value != nextText) {
onValueChange(nextText)
}
textFieldValueState = if (!isComposing) {
text.copy(text = nextText)
} else {
text
}
}
TextField(
value = textFieldValue,
onValueChange = onTextFieldValueChange,
modifier = modifier,
label = label
)
}
動作は以下のようになります。
予測変換中にTextFieldでは最大文字数を超えて表示されますが、onValueChange
では最大文字数までのテキストが返され、また文字列が確定したタイミングで最大文字数までに切り取られます。
動作可能なサンプルをここから確認することができます。
先程は予測変換中は最大文字数を超えて表示できるようにしましたが、予測変換中に送信ボタンを押した場合に表示されている文字列とは異なるもの(短いもの)が送信される等、わかりにくさがあるかもしれません。 今度は、予測変換中も表示文字数を厳格にしてみようと思います。
VisualTransformationを使うことで、TextFieldに実際に入力されている文字列と、表示する文字列を変換することができます。 これは、例えばパスワードの入力時に「*」に変換するため等に使われます。(参考:PasswordVisualTransformation)
今回は最大文字数を超えて入力されていても最大文字数分のみを表示させたいので、以下のような VisualTransformation
を作成しました。
class MaxLengthVisualTransformation(
private val maxLength: Int
) : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
return TransformedText(
AnnotatedString(text.text.take(maxLength)),
maxOffsetMapping
)
}
private val maxOffsetMapping = object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
return min(offset, maxLength)
}
override fun transformedToOriginal(offset: Int): Int {
return offset
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is MaxLengthVisualTransformation) return false
if (maxLength != other.maxLength) return false
return true
}
override fun hashCode(): Int {
return maxLength.hashCode()
}
}
filter
の中で実際の文字列から表示する文字列への変換を行います。
今回のように表示している文字列と実際の文字列で長さが違う場合、OffsetMappingで対応を教えてあげる必要があるので、注意してください。
equals
や hashCode
をoverrideしているのはrecomposeを抑制するためのものです。
なくても動作はしますが、PasswordVisualTransformation
に合わせて実装しました。
これを TextField
に渡して利用します。
TextField(
value = textFieldValue,
onValueChange = onTextFieldValueChange,
visualTransformation = MaxLengthVisualTransformation(maxLength),
modifier = modifier,
label = label
)
動作は以下のようになります。
AndroidViewのEditTextでmaxLength
を指定したときとほとんど同じ挙動になりました。
一方で、予測変換中に入力している文字列を確認できなくなるため、どちらが良いかは意見が分かれるかもしれません。
完全なコードはこちらを確認してください。
先程までは最大文字数を超えて入力できないようにする方法をはなしてきましたが、勝手にトリミングされるのは気づきにくく、エラーにしてあげたほうが親切かもしれません。 こちらのIssueでもエラーメッセージを表示することが推奨されていました。
先ほどと同じく VisualTransformation
を使うことで、はみ出した文字を赤色で表示することができます。
class MaxLengthErrorTransformation(
private val maxLength: Int,
private val errorStyle: SpanStyle = SpanStyle(color = Color.Red)
) : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
return TransformedText(
AnnotatedString(
text = text.text,
spanStyles = if (text.length > maxLength) {
listOf(AnnotatedString.Range(errorStyle, maxLength, text.length))
} else {
emptyList()
}
),
OffsetMapping.Identity
)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is MaxLengthErrorTransformation) return false
if (maxLength != other.maxLength || errorStyle != other.errorStyle) return false
return true
}
override fun hashCode(): Int {
return maxLength.hashCode()
}
}
先ほどと違って変換前後で文字列の長さが変わらないので、OffsetMapping
には OffsetMapping.Identity を使っています。
動作は以下のようになります。
こちらも完全なコードをGitHubにあげています。
TextFieldで最大文字数制限をつけるためのいくつかの方法について紹介してきました。
エラー表示にするのはわかりやすい一方、送信時に検証する必要があったり、送信ボタンをdisableにしたりと別の対応が必要になります。 個人的には、文字数のカウンター等をつけておけば、文字数制限を超えて入力できないようにして良いと考えています。
Jetpack Composeは足りないものが色々ある一方、カスタマイズはしやすく、いろいろな表現が可能なので書いていて楽しいです。
引き続きJetpack Composeの細かいTipsについて紹介していこうと思うので、ぜひTwitterをフォローしてお待ち下さい。