Jetpack ComposeでTextFieldにフォーカスした際にキーボードで隠れないようにする（BringIntoViewRequester）

`TextField`でフォーカスした際にキーボードで入力欄が隠れてしまうことがあります。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58367/9f9de277-8fdb-9911-4e73-f0664b87346b.gif" width="200px">


横画面はもちろん、縦画面でもコンテンツ数が多い場合はよくある問題かと思います。
本記事では`BringIntoViewRequester`を用いた対応方法についてまとめました。

# BringIntoViewRequesterとは？
```
For instance, you can call BringIntoViewRequester.bringIntoView 
to make all the scrollable parents scroll so that the specified item is brought into parent bounds.
```
引用元：[BringIntoViewRequester](https://developer.android.com/reference/kotlin/androidx/compose/foundation/relocation/BringIntoViewRequester)

「親がスクロール可能な場合に`bringIntoView`を呼び出すことにより、親をスクロールさせ指定されたアイテムが親の境界に入るように動作させることが出来ます」とのことです。

その為、含める際には対象の`TextField`だけではなく`親Compose`への注意も必要になります。
スクロール可能にしておく事や、キーボード表示領域への対応も必要になります。

### 導入の前に注意点
`BringIntoViewRequester`は`ExperimentalFoundationApi`に指定されております(2022年3月現在)
```
This foundation API is experimental and is likely to change or be removed in the future.
```
引用元：[ExperimentalFoundationApi](https://developer.android.com/reference/kotlin/androidx/compose/foundation/ExperimentalFoundationApi)

引用元にも記載があるように実験的なものになっているため、将来的に変更または削除される可能性があります。
その為、プロダクトに導入する際は注意して下さい。

# 対応方法
### TextFieldのBringIntoViewRequester対応
`BringIntoViewRequester`を利用することで実現できます。
以前は[RelocationRequester](https://developer.android.com/reference/kotlin/androidx/compose/ui/layout/RelocationRequester)を利用していましたが、こちらは`deprecated`になっています。

まず、スクロール先の指定になります。
スクロール対象の`Compose`の`Modifier`に対して
`bringIntoViewRequester`で`Requester`を指定します。
これを行うことで`Requester`に対して要求した際にこちらにスクロールするようになります。

次にスクロールの要求になります。
こちらは`Requester`の`bringIntoView`を呼び出すことによりスクロールが実行されます。
`TextField`に対してフォーカスした際に発火させるには`onFocusEvent`を利用します。
`onFocusEvent`で`State`が`Focused`の際にのみ`bringIntoView`を実行させています。

途中に含まれている`delay`に関しましては本来必要無いのですが、
キーボードのアニメーションと被る場合に動作しなくなる為含めています。

```Sample.kt
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun BringIntoTextFiled() {
    val requester = remember { BringIntoViewRequester() }
    val coroutineScope = rememberCoroutineScope()
    var text by remember { mutableStateOf("hogehoge") }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .verticalScroll(rememberScrollState())
            .padding(vertical = 1000.dp)
    ) {
        TextField(
            value = text,
            onValueChange = { text = it },
            modifier = Modifier
                .bringIntoViewRequester(requester)
                .onFocusEvent { focusState ->
                    if (focusState.isFocused) {
                        coroutineScope.launch {
                            delay(200)
                            requester.bringIntoView()
                        }
                    }
                }
        )
    }
}
```

### キーボード表示領域に対応する
上記の該当箇所の対応のみでは上手く自動スクロールが出来ません。
理由としましてはキーボードが表示された際に`Activity`の表示領域とキーボードの表示領域が重なっており、
対象の`TextField`が領域外になっていないという判定の為です。

対応として`adjustResize`を`Activity`に対して設定すると、
キーボードが表示された際にActivityの表示領域がキーボードの表示領域に合わせて変更される為、
上手く動作するようになります。

```AndroidManifest.xml
<activity
    android:name=".SampleActivity"
    android:windowSoftInputMode="adjustResize"/>
```

これで`TextField`へのフォーカス時に自動スクロールされるようになりました。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58367/b0085d69-7590-3ac2-0468-eda5a7e6f26a.gif" width="200px">

# LazyColumnを用いた場合の挙動について
LazyColumnを用いた場合ですが上手く動作させることが出来ませんでした。
フォーカスし、キーボードが表示された直後すぐにキーボードが非表示になってしまいました。
こちらについては後日調査&対応しようと思います。

# 入力欄が複数ある場合について
`BringIntoViewRequester`は`TextField`に対して個別に作成する必要があります。
以下の`Failed Case`のように共通で利用した場合は想定した動作になりません。

### ✗ Failed Case
```Failed Case.kt
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SampleField() {
    val requester = remember { BringIntoViewRequester() }
    val coroutineScope = rememberCoroutineScope()
    var text by remember { mutableStateOf("hogehoge") }

    TextField(
        value = text,
        onValueChange = {
            text = it
        },
    Column {
        TextField(
            value = "hogehage",
            onValueChange = {},
            modifier = Modifier
                .bringIntoViewRequester(requester)
                .onFocusEvent { focusState ->
                    if (focusState.isFocused) {
                        coroutineScope.launch {
                            delay(200)
                            requester.bringIntoView()
                        }
                    }
                }
        )
        TextField(
            value = "hogehage",
            onValueChange = {},
            modifier = Modifier
                .bringIntoViewRequester(requester)
                .onFocusEvent { focusState ->
                    if (focusState.isFocused) {
                        coroutineScope.launch {
                            delay(200)
                            requester.bringIntoView()
                        }
                    }
                }
        )
    }
}
```

### ○ Success Case
```SuccessCase.kt
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SampleField() {
    val requester1 = remember { BringIntoViewRequester() }
    val requester2 = remember { BringIntoViewRequester() }
    val coroutineScope = rememberCoroutineScope()

    Column {
        TextField(
            value = "hogehage",
            onValueChange = {},
            modifier = Modifier
                .bringIntoViewRequester(requester1)
                .onFocusEvent { focusState ->
                    if (focusState.isFocused) {
                        coroutineScope.launch {
                            delay(200)
                            requester1.bringIntoView()
                        }
                    }
                },
        )
        TextField(
            value = "hogehage",
            onValueChange = {},
            modifier = Modifier
                .bringIntoViewRequester(requester2)
                .onFocusEvent { focusState ->
                    if (focusState.isFocused) {
                        coroutineScope.launch {
                            delay(200)
                            requester2.bringIntoView()
                        }
                    }
                },
        )
    }
}
```

上記の`Success Case`も冗長なので1つの`Component`としてまとめ使い回せるようにしましょう。

以上