🎃

【Jetpack Compose】TextFieldの入力箇所が隠れないよう、自動でScrollする画面を作成する

2024/04/17に公開

株式会社バニッシュ・スタンダードアプリチームの中本です。弊社で提供している「STAFF START」というサービスのandroidアプリ開発を担当しています。

弊社では新規機能を開発するにあたりJetpack Composeを用いています。やっぱり書きやすいですよね、Jetpack Compose。ただ、痒いところに手が届かないところがあるのも事実です。とある機能を作る際、入力文字に合わせてTextFieldの枠を広げつつ、入力箇所が常に画面内に収まるように自動でスクロールさせる必要がありました。これが意外と難しくハマってしまったので、現時点での解決策をこの記事にて共有させてもらえればと思います。

作りたいもの

上に平家物語のTextが、下に方丈記のTextがあり、その間にTextFieldを設置します。入力欄が下にいくのと同時にTextFieldの枠が広がりつつ、かつ入力箇所が画面内に収まるように自動でスクロールするようなものを作ります。

コード

入力箇所のy座標を逐一取得し、その値が画面の上か下かを判定しました。判定に合わせてスクロールの位置を変更することで、自動Scrollを実現しました。
入力箇所のy座標を取得するためにonTextLayoutを用いたのですが、TextFieldの引数としては設定されていないため、BasicTextFieldを用いました。
なお、Composeについてはbomの2023.08.00を用いました。

@Composable
private fun ScrollableTextField() {

    val context = LocalContext.current
    val activity = LocalContext.current as Activity
    var textFieldValue by remember { mutableStateOf(TextFieldValue("")) }
    val scope = rememberCoroutineScope()
    val scrollState = rememberScrollState()

    var topTextHeight by remember { mutableStateOf(0) }
    var bottomTextHeight by remember { mutableStateOf(0) }
    val verticalPadding = 30
    val paddingPx = dpToPx(30 * 2, context)//上下にpaddingをつけたため、2で乗する

    Column(modifier = Modifier.verticalScroll(scrollState)) {
        Text(text = MainActivity.heike,
            fontSize = 18.sp,
            modifier = Modifier
                .padding(vertical = verticalPadding.dp)//paddingはtopTextHeightに含まれない
                .onGloballyPositioned {
                    topTextHeight = it.size.height
                })
        BasicTextField(modifier = Modifier
            .defaultMinSize(minHeight = 200.dp),
            value = textFieldValue,
            onValueChange = {
            textFieldValue = it
        }, onTextLayout = { textLayoutResult ->
            val cursorLine = textLayoutResult.getLineForOffset(textFieldValue.selection.start)
            val cursorYCoordinate =
                textLayoutResult.getLineBottom(cursorLine).toInt()//このy座標はBasicTextField上での相対値

            val r = Rect()
            val rootView: View = activity.window.decorView
            rootView.getWindowVisibleDisplayFrame(r)
            val screenHeight = r.bottom - r.top//ここでscreenHieghtを取得することで、SoftwareKeyboardが開いても対応できる

            scope.launch {
                if (topTextHeight + paddingPx + cursorYCoordinate < scrollState.value) { //screenの上に隠れた時
                    scrollState.scrollTo(
                        topTextHeight + paddingPx + cursorYCoordinate - 30 //30は適当な調整値
                    )
                } else if (topTextHeight + paddingPx + cursorYCoordinate + 30 > scrollState.value + screenHeight //30は適当な調整値
                ) {//screenの下に隠れた時
                    scrollState.scrollTo(
                        topTextHeight + paddingPx + cursorYCoordinate + 30 - screenHeight
                    )
                }
            }
        }, decorationBox = { innerTextField ->
            Box(
                Modifier
                    .fillMaxWidth()
                    .defaultMinSize(minHeight = 200.dp)
            ) {
                innerTextField()
                if (textFieldValue.text.isEmpty()) {
                    Text(text = "コメントを入力してください", color = Color.Gray)
                }
            }
        })
        Text(text = MainActivity.hojoki,
            fontSize = 18.sp,
            modifier = Modifier
                .padding(vertical = verticalPadding.dp)
                .onGloballyPositioned {
                    bottomTextHeight = it.size.height
                })
    }
}

ちょっとだけ解説します

  • y座標はpx単位なので、dp単位の値はpx単位に変換する必要があります
  • cursorYCoordinateはBasicTextField上での相対的なy座標なので、入力箇所の絶対y座標を取得するためには、平家物語の部分(+padding)の高さを加える必要があります。そのため、絶対y座標はtopTextHeight + paddingPx + cursorYCoordinateになります
  • 先の値がscrollState.valueより小さければ画面の上に隠れてます。scrollState.value + screenHeightより大きければ、画面の下に隠れています
  • 画面の下に隠れているかを判定する部分で、topTextHeight + paddingPx + cursorYCoordinate > scrollState.value + screenHeightとしてしまうと、文字上部が切れてしまうことがあるため、調整値として30を足しています
  • screenHeight自体はactivityでも取れると思いますが、ここで取得することによりSoftwareKeyboardの開閉に対応することができます。まぁ、閉じていることを想定する必要はないかもしれませんが
  • BasicTextFieldのvalueにはTextFieldValue型の値を渡す必要があります。Stringではできません

また、今回の内容とは直接関係ないのですが、decorationBoxを用いてプレースホルダーを表示させる部分には若干注意が必要です。

decorationBox = { innerTextField ->
            Box(
                Modifier
                    .fillMaxWidth()
                    .defaultMinSize(minHeight = 200.dp)
            ) {
                if (textFieldValue.text.isEmpty()) {
                    Text(text = "コメントを入力してください", color = Color.Gray)
                } else {
                    innerTextField()
                }
            }

とやってしまいそうですが、これだと何も入力していないときにクラッシュします。これはdecorationBox内で一回はinnerTextField()を呼ばなければならないためです。

抑えどころはこんなところでしょうか。
予測変換が開いた時の挙動が若干安定していないです(なぜかはわかってないです)。Jetpack Composeでもっと簡単に実装できるようになればいいですね。他にこんな方法もあるよという方がいらっしゃいましたら、コメントいただければありがたいです!

Discussion