Blog

Jetpack ComposeのTextFieldで最大文字数を制御する

Jetpack Composeがstableになりしばらく経ちますが、利用しているとAndroid Viewにあった様々なものが、まだ用意されていないことに気が付きます。

テキストを入力できる TextField は、まだ最大文字数( maxLength)を設定することができません。

Issue: TextFieldValue is reset unexpectedly with multibyte charactors.

今回は最大文字数を設定するいくつかの方法について紹介を行います。

TextFieldの使い方

念の為、TextFieldの基本的な使い方について紹介をします。

Composaは基本的にコンポーネント内部に状態を持たせず、利用する側でStateを管理します。

TextFieldに関しても、入力中のテキストはすべて外側で管理する必要があります。

TextFieldには valueonValueChangeのパラメータがあり、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で対応を教えてあげる必要があるので、注意してください。

equalshashCode をoverrideしているのはrecomposeを抑制するためのものです。 なくても動作はしますが、PasswordVisualTransformation に合わせて実装しました。

これを TextField に渡して利用します。

TextField(
    value = textFieldValue,
    onValueChange = onTextFieldValueChange,
    visualTransformation = MaxLengthVisualTransformation(maxLength),
    modifier = modifier,
    label = label
)

動作は以下のようになります。

AndroidViewのEditTextmaxLengthを指定したときとほとんど同じ挙動になりました。

一方で、予測変換中に入力している文字列を確認できなくなるため、どちらが良いかは意見が分かれるかもしれません。

完全なコードはこちらを確認してください。

文字数を超えた場合にエラー表示にする

先程までは最大文字数を超えて入力できないようにする方法をはなしてきましたが、勝手にトリミングされるのは気づきにくく、エラーにしてあげたほうが親切かもしれません。 こちらの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をフォローしてお待ち下さい。

人気の記事

kotlin coroutinesのFlow, SharedFlow, StateFlowを整理する

Jetpack ComposeとKotlin Coroutinesを連携させる

Layout Composableを使って複雑なレイアウトを組む【Jetpack Compose】

テスト用Dispatcherの使い分け【Kotlin Coroutines】

Flow.combineの内部実装がすごい話

Jetpack ComposeのRippleエフェクトを深堀り、カスタマイズも