Compose TextFieldのキーボード対応(Compose Multiplatform含む)



記事の内容としては、テキストフィールドを押したときに出てくるキーボードの振る舞いについてです。

最近個人的にCompose Multiplatformでアプリを試していて、両OSのキーボード対応を調べました。
発端はMultiplatformですが、あまり整理できていなかったので、この機会にまとめようと思います。

keyboardOptionsと、keyboardActionsを扱った後に、キーボードを出したときにテキストフィールドを隠さない設定などを書きたいと思います。

## keyboardOptions

[KeyboardOptions](https://developer.android.com/reference/kotlin/androidx/compose/foundation/text/KeyboardOptions)の各パラメータについての説明です。

|パラメータ | 説明 |
|:---|:---|
| capitalization | 文字、単語、または文を自動的に大文字にするかどうかをキーボードに指示|
| autoCorrectEnabled | 自動修正を有効にするかどうかをキーボードに指示。KeyboardType.EmailとKeyboardType.Uriで有効 |
| keyboardType | テキストフィールドで使用するキーボードタイプ。Unspecified, Text, Ascii, Number, Phone, Uri, Email, Password, NumberPassword, Decimalがある。数字だけ入力するキーボードなどを指定 |
| imeAction | IME アクション。Unspecified, Default, None, Go, Search, Send, Previous, Next, Doneがある。リターンキーの見た目が変わり、挙動も変わる。 |
| platformImeOptions | プラットフォーム固有の IME オプション |
| showKeyboardOnFocus | デフォルトでtrueで、フォーカスしたときにキーボードを自動で表示する |
| hintLocales | キーボードで言語切替をするときに、候補として表示させたいものを指定 |

日本語圏に限定するなら、このなかではkeyboardTypeとimeActionをよく使うのではないかと思います。

iOSのswift ui開発なら、keyboardTypeとsubmitLabelというmodifierが相当するようです。

こちらどのようなキーボードになるか、視覚的に整理しておきましょう。

（OSや機種、設定等によって画像とは異なる結果になることもある点に注意）



keyboardType | Android | iOS 
---|---|---
KeyboardType.Unspecified | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/c3311e85-9034-e3c1-a626-55f9921fdad8.png" width="200px"> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/1d713a7e-769d-ab6c-a92a-79d8e20ac200.png" width="200px">
KeyboardType.Text | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/f1bd1686-bea3-5f7d-86f8-1b2759cb497d.png" width="200px"> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/e8378acb-491a-cb49-8938-36e0c1eca482.png" width="200px">
KeyboardType.Ascii | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/7a3da974-87cb-14cf-a355-e32cd04346b3.png" width="200px"> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/79db4df8-2bb6-823e-5365-08efa53648ce.png" width="200px">
KeyboardType.Number | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/dce268a8-04e1-f875-f6f3-2a2b9cc3c190.png" width="200px"> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/0878ee46-a12c-a0c9-a24d-cdbc9d7f5191.png" width="200px">
KeyboardType.Phone | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/32ca3634-fae0-3c6d-9871-ce462fc0bcf6.png" width="200px"> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/6678d270-641c-d2d7-04f7-020d38edc2ae.png" width="200px">
KeyboardType.Uri | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/f9d81cb4-c831-0ed7-6cfa-6c058f6fd8c7.png" width="200px"> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/fb7cfc0a-8eab-2887-e3ad-ab07a9ce2cef.png" width="200px">
KeyboardType.Email | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/4651daa0-fcdd-ab59-66aa-390c197f9a8e.png" width="200px"> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/316a5d15-1663-fb73-2302-5a8f982bb494.png" width="200px">
KeyboardType.Password | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/8bf7e5b3-0f79-57a7-ad66-d96417a91890.png" width="200px"> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/eb33f907-1db2-2c8a-e613-e043a2953499.png" width="200px">
KeyboardType.NumberPassword | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/55201402-9bed-cb1d-4970-7430c9892721.png" width="200px"> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/30fcaba2-d965-b729-b83a-94b72c952f5b.png" width="200px">
KeyboardType.Decimal | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/93719b6a-493f-60ba-5dfe-46db0114aa3c.png" width="200px"> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/7538777b-f631-9a67-9b76-4f14f904c5b4.png" width="200px">


imeAction | Android | iOS 
---|---|---
ImeAction.Unspecified | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/5615b1c2-6448-70e8-6db9-963e8830ed6b.png" width="200px"> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/6308e280-5dc4-c033-6c28-ec9b23ded3a7.png" width="200px">
ImeAction.Default | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/bb266afb-aa94-3cd6-d30f-991000897070.png" width="200px"> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/64b9a036-3a45-fcdb-d660-255d2e1c30bc.png" width="200px">
ImeAction.None | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/1bdba7cd-1a71-bac7-c14d-caf14ea6788e.png" width="200px"> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/d2572fea-8a18-11d2-b5d8-20cab139e4a6.png" width="200px">
ImeAction.Go | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/27d7028e-7ac3-49bc-b5d6-236add7b0ffa.png" width="200px"> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/67c56a12-e43c-d799-57b5-fadaf9f10d60.png" width="200px">
ImeAction.Search | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/080c43bc-79dd-a34c-697d-107e161d6b0c.png" width="200px"> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/a11814e5-5cac-a5b8-abef-9b40309d120d.png" width="200px">
ImeAction.Send | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/c7a3c2bd-47fc-0cd7-0660-4b9252112375.png" width="200px"> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/1f634c27-17ec-0295-d6cc-70bdb4e21111.png" width="200px">
ImeAction.Previous | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/05b35061-5faf-f5a7-418a-e35bcbc62cb5.png" width="200px"> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/d22efd29-1075-6589-3b6b-7e026cc2266e.png" width="200px">
ImeAction.Next | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/5e54b5d0-ce49-ab16-0d8b-765f0aa3f53b.png" width="200px"> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/3c1f2bb1-de8b-1baa-e1ec-9c679cdcb10a.png" width="200px">
ImeAction.Done | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/be561849-15e8-2433-c4f6-59172a7ad737.png" width="200px"> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/fb6cd2f6-249d-45ff-c483-105af7ff738b.png" width="200px">



## keyboardActions

imeActionで、returnのようなキーを押したときにトリガーされる操作を実装できます。
たとえば、imeAction.Sendしたときに、リクエストを送るようなことができます。

## キーボードでビューが隠れる問題への対応

何もしなったり設定を誤ると、キーボードが現れたときに、意図せずビューが上に行ってしまったり、キーボードで後ろのビューが隠れてしまいます。

そのときはmanifestのactivityに、`android:windowSoftInputMode="adjustResize"`を追加します。これがないと、背景のビューが上に行ってしまい、上の部分が隠れてしまうようです。

しかしこの設定だけでは不十分です。この設定だけだとキーボードで後ろのビューが隠れてしまいます。imePaddingという修飾子が用意されているので、これを使えばテキストフィールドがキーボードに隠れないように、キーボードが現れた時だけ自動でpaddingを追加することができます。

```kotlin
@Composable
fun KeyboardPlayground(modifier: Modifier = Modifier) {
    Column(
        modifier = modifier
            .imePadding()
            .verticalScroll(rememberScrollState()),
    ) {
        AllKeyboardTypeTextField()
        AllImeActionTextField()
    }
}
```

注意すべきは、imePaddingとverticalScrollの順番。上記を逆にすると、キーボードが現れた瞬間テキストフィールドはキーボードに隠れてしまいます。

なぜかというと、余白がどこに追加されるか考えると理解できます。正しいimePadding.verticalScrollの順番だと、キーボードの余白は表示したいコンテンツの外に配置されます。逆だと、スクロール対象のコンテンツの一番下に配置されます。

正しい順序だとキーボードが出現するとコンテンツの表示領域は狭まり、コンテンツはオフセットされるような形になりテキストフィールドは見えます。

正しくない逆の順序だと、キーボードが出現してもコンテンツの表示領域は変わりません。スクロール量はキーボードが現れたところで変わらないので、テキストフィールドが隠れてしまいます。

ここは言葉だけで説明できる自身がないので図を書いておきます。
（赤枠で囲ったものがフォーカス対象のテキストフィールドです。）

![スクリーンショット 2024-12-21 22.20.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1165483/336db0bb-6a9b-edad-090c-4b8e042261dd.png)


またimePaddingは外側にあるComposableに置いてもよいです。上記の場合だとKeyboardPlayground()の外側にScaffoldとかがあるなら、そちらに設定してもよいです。

## Modifier.bringIntoViewRequester

`Modifier.bringIntoViewRequester`を使って、ビューが表示領域に入るような実装を入れることができます。
なにかの条件が揃った時や、アクションがあったとき、自動でスクロールさせて特定のコンポーネントを表示させたいときに使います。
テキストフィールドフォーカスでテキストフィールドを表示したいような場合は前項のやり方で事足りるので、これを使わなくても良いと思います。
テキストフィールド以外でも対応できます。

以下は[公式のサンプル](https://developer.android.com/reference/kotlin/androidx/compose/foundation/relocation/BringIntoViewRequester)

```kotlin
@Composable
fun BringIntoViewSample() {
    Row(Modifier.horizontalScroll(rememberScrollState())) {
        repeat(100) {
            val bringIntoViewRequester = remember { BringIntoViewRequester() }
            val coroutineScope = rememberCoroutineScope()
            Box(
                Modifier
                    // This associates the RelocationRequester with a Composable that wants to be
                    // brought into view.
                    .bringIntoViewRequester(bringIntoViewRequester)
                    .onFocusChanged {
                        if (it.isFocused) {
                            coroutineScope.launch {
                                // This sends a request to all parents that asks them to scroll so
                                // that this item is brought into view.
                                bringIntoViewRequester.bringIntoView()
                            }
                        }
                    }
                    .focusTarget()
            )
        }
    }
}
```

## キーボード外の領域タップでキーボードを閉じたい時

親領域のComposableをフォーカス可能にして、`FocusRequester.requestFocus()`で、親にフォーカスを当てることで、テキストフィールドのフォーカスを外します。
indicationを指定しているのは、リップルエフェクトをなくすためです。

```kotlin
@Composable
fun KeyboardPlayground(modifier: Modifier = Modifier) {
    val focusRequester = remember { FocusRequester() }
    val interactionSource = remember { MutableInteractionSource() }
    Column(
        modifier = modifier
            .imePadding()
            .verticalScroll(rememberScrollState())
            .focusRequester(focusRequester)
            .focusable()
            .clickable(interactionSource = interactionSource, indication = null) {
                focusRequester.requestFocus()
            }
    ) {
        AllKeyboardTypeTextField()
        AllImeActionTextField()
    }
}
```

こちらの記事様([Jetpack Composeでも背景押したらTextFieldのフォーカスを外す \- たくさんの自由帳](https://takusan.negitoro.dev/posts/android_jc_focus_requester/))を参考にさせていただきました。

## Compose multiplatformの注意点

これまで述べてきたものはCompose Multiplatformに対応していて、iOSでも同様の挙動が可能になります。

ただOSのキーボードの違いはあります。
iOSはnumberの場合にはreturnが表示されない、というものがありました。
こちらはOSのキーボードの仕様なので、注意しておきたいところです。

## さいごに

いろいろな実装方法がありそうですが、自分のやり方をまとめてみました。
間違っているところがあればご指摘いただけると嬉しいです。

[こちら](https://github.com/hiroaki404/PublicPlayground/blob/main/app/src/main/java/com/example/publicplayground/keyboard/KeyboardPlayground.kt)にコードを公開しているのでよかったら参考までに。(ディレクトリ整理等でurl変更になってしまっていたら、[こちら](https://github.com/hiroaki404/PublicPlayground/tree/main))