Jetpack ComposeのTextFieldで文字列を中央表示し、placeholderを表示しているときだけカーソルを左端に寄せたい

こんにちは、Androidエンジニアの @syarihu です。mikanでは副業メンバーとしてお手伝いをさせていただいています。

今回はJetpack ComposeでのちょっとしたTipsとして、TextFieldの中央にplaceholderを表示し、カーソルをplaceholderの左端に寄せる方法について紹介します。

実現したいこと

文章だけだと何を言っているのか分かりにくいと思うので、とりあえず最終的にどういう形にしたいのか、デザインを見てみましょう。

これはmikanの初回起動時に表示されるオンボーディング画面内のニックネーム入力フォームです。中央に「れもん」というplaceholderを表示し、その左端にカーソルが置かれています。

一見簡単そうに見えますが、これを実現するにはちょっとした工夫が必要です。

普通にやろうと思うとどのような課題があるのか、それをどう解決したのかを順を追って説明します。

Jetpack ComposeのTextFieldでplaceholderを中央配置したときの挙動

ComposeのTextFieldで普通にplaceholderを中央配置しようとすると、次のようなコードになります。

var nickname: TextFieldValue by remember { mutableStateOf(TextFieldValue()) }
Box(modifier = Modifier.padding(20.dp)) {
  OutlinedTextField(
    value = nickname,
    onValueChange = { textFieldValue ->
      nickname = textFieldValue
    },
    shape = RoundedCornerShape(8.dp),
    colors = TextFieldDefaults.textFieldColors(
      // ...
    ),
    singleLine = true,
    placeholder = {
      Text(
        text = "れもん",
        color = Color.hint,
        textAlign = TextAlign.Center,
        modifier = Modifier.fillMaxWidth(),
        fontSize = 16.sp,
      )
    },
    textStyle = LocalTextStyle.current.copy(
      textAlign = TextAlign.Center,
      fontSize = 16.sp,
    ),
    modifier = Modifier.fillMaxWidth(),
  )
}

これを実行すると、次のように表示されます。

このように、普通にやろうとするとテキストを中央配置している都合でカーソルがplaceholderの真ん中に表示されてしまうことが分かります。

これをどうにかカーソルだけ左に持っていく方法を考えてみます。

Jetpack ComposeのTextFieldでplaceholderを中央配置しつつ、カーソルをplaceholderの左端に持っていく方法を考える

カーソルの表示は入力された文字列に依存しているため、当然入力文字列が存在しなければカーソルの位置をずらすことはできません。また、文字列を入力すると通常のplaceholderの仕組みを使うことはできません。

そのため、通常のplaceholderの仕組みを使わないアプローチとして、TextFieldの入力文字列が空のときにplaceholderの代わりにコードから文字列を入力し、placeholderとして入力されている文字列があるときは入力文字列の色をplaceholderの色に変えてみることを考えました。

はじめに、placeholderの表示有無をshowPlaceholderとして持っておき、showPlaceholderがtrue(テキストが空)のときにテキストの色をplaceholder用に変えて、テキストが入力されたときにonValueChanged内でsuffixにあるplaceholderを削除して改変するようなコードを書いてみました。

var nickname: TextFieldValue by remember {
  mutableStateOf(TextFieldValue())
}
val showPlaceholder: Boolean by remember(nickname.text) {
  mutableStateOf(nickname.text.isEmpty())
}

Box(modifier = Modifier.padding(20.dp)) {
  OutlinedTextField(
    value = if (showPlaceholder) TextFieldValue(text = "れもん") else nickname,
    onValueChange = { textFieldValue ->
      nickname = textFieldValue.copy(text = textFieldValue.text.removeSuffix("れもん"))
    },
    shape = RoundedCornerShape(8.dp),
    colors = TextFieldDefaults.textFieldColors(
      textColor = if (showPlaceholder) Color.hint else Color.textMain,
      // ...
    ),
    singleLine = true,
    placeholder = {
      Text(
        text = "れもん",
        color = Color.hint,
        textAlign = TextAlign.Center,
        modifier = Modifier.fillMaxWidth(),
        fontSize = 16.sp,
      )
    },
    textStyle = LocalTextStyle.current.copy(
      textAlign = TextAlign.Center,
      fontSize = 16.sp,
    ),
    modifier = Modifier.fillMaxWidth(),
  )
}

これを実行すると、次のようになります。

この対応でカーソル位置を左に寄せることは実現できました。

しかし、placeholderから切り替わったタイミングで一文字目が自動で確定され、2文字目以降しか変換できなくなってしまいました。placeholderが変わったときにTextFieldが内部で持っているvalueと違うTextFieldValueが設定されてしまい、そのタイミングで1文字目が自動で確定されてしまうのだと思います。

また、次のようにTextFieldValueのcompositionを一文字目から明示的に指定するのも試してみました。

OutlinedTextField(
  value = if (showPlaceholder) TextFieldValue(text = "れもん") else nickname,
  onValueChange = { textFieldValue ->
        val newText = textFieldValue.text.removeSuffix("れもん")
      nickname = textFieldValue.copy(
      text = newText,
      composition = TextRange(0, newText.length)
    )
  },

これを実行すると次のようになります。

先ほどよりもさらにひどくなり、すべての文字列が未確定状態となっているものの、変換候補が表示されず別々の文字列として確定されてしまいました。

TextFieldValueは別の値に変えようと思うとプロパティが読み取り専用のためcopyすることになるのですが、copyすると値は引き継がれますが別のインスタンスが生成されます。

ユーザーが入力し、onValueChangedで通知された値をcopyしてTextFieldValueを別のインスタンスにしてしまうと、TextField内で保持しているものと違うTextFieldValueとなってしまうため、入力途中の文字列としては扱われなくなってしまうことがわかりました。

そのため、この方法ではカーソル位置を一番左にすることはできますが、入力を自然に行うことはできないため、このアプローチは使えないことがわかりました。

VisualTransformationを使って入力文字列が空のときの見た目だけ変えて、カーソルを常に左端に寄せつつplaceholderっぽいものを作る

若干諦めかけていたものの、ComposeのTextFieldにはVisualTransformationという仕組みがあることを思い出しました。

VisualTransformationは例えばパスワード入力時などに実際の入力文字列とは別に見た目だけマスクするなどの用途で使用できるものですが、そういった既に用意されているVisualTransformationとは別に、独自でVisualTransformationを作ることもできます。

これをうまく使って、入力文字列が空のときに見た目だけplaceholderっぽいものを表示して、なにか入力されたら入力文字列をそのまま表示してあげれば、実際の入力文字列に影響を与えずにうまく動作させられるのではと考えて試してみました。

実際に作ったVisualTransformationは次のようなコードです。

private fun placeHolderVisualTransformation(
  placeholder: String,
): VisualTransformation = VisualTransformation { text ->
  val showPlaceHolder = text.text.isEmpty()
  TransformedText(
    AnnotatedString(text = text.text.ifEmpty { " $placeholder " }),
    object : OffsetMapping {
      override fun originalToTransformed(offset: Int): Int =
        if (showPlaceHolder) 0 else offset

      override fun transformedToOriginal(offset: Int): Int =
        if (showPlaceHolder) 0 else offset
      }
  )
}

placeholderとして表示する文字列を引数にとる関数を作成します。入力テキストが空の場合は表示文字列をplaceholderに変えておきます。

originalToTransformedとtransformedToOriginalは入力文字列とTransformedTextでテキストの長さに違いがある場合にカーソル位置を調整するものですが、placeholderとして扱いたいため常に左端にカーソルがいくように、placeholderの表示中はoffsetを0として返すようにします。

あとは、TextFieldのvisualTransformationに設定しつつ、次のようなコードを書きます。

var nickname: TextFieldValue by remember {
  mutableStateOf(TextFieldValue())
}
val showPlaceholder: Boolean by remember(nickname.text) {
  mutableStateOf(nickname.text.isEmpty())
}

Box(modifier = Modifier.padding(20.dp)) {
  OutlinedTextField(
    value = nickname,
        onValueChange = { textFieldValue ->
      nickname = textFieldValue
    },
    shape = RoundedCornerShape(8.dp),
    colors = TextFieldDefaults.textFieldColors(
      textColor = if (showPlaceholder) Color.hint else Color.textMain,
      // ..
    ),
    singleLine = true,
    textStyle = LocalTextStyle.current.copy(
      textAlign = TextAlign.Center,
      fontSize = 16.sp,
    ),
    modifier = Modifier.fillMaxWidth(),
    visualTransformation = placeHolderVisualTransformation(placeholder)
  )
}

あくまで入力文字列の見た目を変えているだけなので、最初に考えたアプローチと同じようにテキストカラーはplaceholderの表示中はplaceholderの色に変える必要があります。

本来のTextFieldのplaceholderはvisualTransformationを使うことで不要になったので、placeholder引数の指定は削除しています。

これを実行してみると、次のようになります。

1文字目が自動で確定されることなく、入力した文字列がすべて変換対象となり変換候補も表示され、デザイン通りの見た目で自然に入力させることができました 🎉

おわりに

若干諦めかけましたが、なんとか期待どおりのUIを作ることができて良かったです。もし似たようなケースを実装することがあれば、VisualTransformationを試してみてください。

最後に、僕がお手伝いしているmikanでは正社員のAndroidエンジニアを募集しています。ご興味ある方は下記のリンクからぜひご応募ください!

また、話だけでも聞いてみたい!カジュアル面談してみたい!という方も、応募メッセージにその旨を書いてお気軽に送ってください!よろしくお願いします!