📱

ComposeのTextField がキーボードに隠れてしまう問題:原因分析と解決方法

に公開

Jetpack ComposeのTextFieldがキーボードに隠れてカーソルが見えなくなる問題はよく発生します。
特に複数行入力では入力位置を確認できず、UXに大きな影響があります。

ComposeのBasicTextFieldは自動スクロールやIME対応を提供していないため、
この問題を解決するにはカーソル位置に応じてスクロールを制御する処理を自前で実装する必要があります。

本記事では、この問題がなぜ起きるのか、そして
Composeで実装したカスタムTextFieldによる解決方法を紹介します。

1. 画面構成

val textFieldState = rememberTextFieldState("")
Column(
    modifier = Modifier.fillMaxSize()
) {
    AppBar(...)

    CustomTextField(
        textFieldState = textFieldState,
        modifier = Modifier.weight(1f),
        ...
    )

    BottomSection(
        modifier = Modifier.weight(1f),
        ...
    )
}

上下をweight(1f)で1:1に分割し、
上側に複数行対応のTextFieldBasicTextFieldをラップしたCustomTextField)を配置しています。

2. 正常に動作するケース — TextFieldの高さに全行が収まっている場合

次は、テキストを11行まで入力した状態です。
この場合、すべての行がTextFieldの高さに収まるため、スクロールは不要です。

この状態で任意の行をタップすると、
キーボードが表示されながらタップした行に正しくフォーカスが当たります。

  • キーボードを表示
  • TextFieldの高さが縮む
  • 縮んだ領域内でもカーソルが正常に見える
  • TextFieldが隠れない

つまり、テキストが“画面内に収まっている”状態では、
Composeの標準動作だけでキーボードとTextFieldが正常に連携します。

このため、この構成だけを見ると
「特に問題はなさそうだ」と思ってしまいがちです。

しかし、テキストがさらに増えてTextFieldの高さを超え始めると
ここから問題が発生します → 次のセクションで解説します。

3. 問題が発生するケース — TextFieldの高さを超えた場合

テキストが増えてTextFieldの表示領域を超えるほど複数行になった瞬間、
Composeの標準動作だけではフォーカスが正しく維持されなくなります。

以下は、テキストがTextFieldの高さを超えて
スクロールが必要な状態で任意の行をタップしたときの動作です。

この状況では次の問題が発生します:

  • 行をタップするとキーボード自体は表示される
  • しかしカーソルがタップした位置へ移動しない
  • 画面も適切な位置までスクロールしない
  • 結果としてTextFieldの下部がキーボードに完全に隠れてしまう

つまり、テキストがTextFieldの高さに収まらなくなった時点で、
Compose標準の動作では 「カーソル位置の可視性」 が維持できなくなります。

ユーザー視点では、

「今どこを入力しているのか分からない」

という状態になり、UXに大きな問題を引き起こします。

この問題を解決するには、
カーソル位置を取得し、その位置が常に可視領域に入るようにスクロールを制御する処理
を自前で実装する必要があります。

4. なぜこの問題が起きるのか

この問題の本質的な原因は、ComposeのBasicTextFieldがカーソルの可視性を保証しないことにあります。

BasicTextField

  • カーソルが画面外に出たかどうかを判断せず
  • 出た場合にスクロールで追従する仕組みもなく
  • IME(キーボード)領域も自動で考慮しません。

テキストがTextField内に収まっている間は問題ありませんが、
行数が増えてTextField内でスクロールが必要になった瞬間から、
カーソルが追従しなくなり、キーボードに隠れてしまいます。

つまり、これはComposeの自動スクロール非対応という構造的制限が原因です。

5. 解決方法 — カーソル位置に応じて自動スクロールするCustomTextField

この問題を解決するためには、
カーソルが常に画面内に見えるようにスクロールを制御するTextField
を自前で実装する必要があります。

解決のポイントは次の4つです。

  1. onTextLayoutからTextLayoutResultを取得し、カーソル位置(getCursorRect())を計算
  2. TextFieldの表示領域(top/bottom)を把握
  3. カーソルがその領域をはみ出した場合、必要量だけScrollStateを移動
  4. IME(キーボード)の高さも考慮して可視範囲を計算

以下は実際の CustomTextField の実装コードです。

@Composable
fun CustomTextField(
    textFieldState: TextFieldState,
    modifier: Modifier = Modifier,
) {
    // TextLayoutResult は現在の文字レイアウト情報(カーソル位置取得に必要)
    val latestLayout = remember { mutableStateOf<TextLayoutResult?>(null) }
    val scrollState = rememberScrollState()
    val coroutineScope = rememberCoroutineScope()
    var textFieldHeight by remember { mutableIntStateOf(0) }

    val imeBottom = WindowInsets.ime.getBottom(LocalDensity.current)
    ...

    // カーソルの上下に少し余白をつけるための margin
    val margin = with(LocalDensity.current) { textStyle.lineHeight.toPx() * CURSOR_VERTICAL_MARGIN_RATIO }

    LaunchedEffect(imeBottom, latestLayout.value) {
        val layout = latestLayout.value ?: return@LaunchedEffect

        // キーボードが閉じているときは処理しない
        if (imeBottom <= 0) return@LaunchedEffect

        // 現在のカーソル位置(オフセット)を取得
        val textLength = layout.layoutInput.text.length
        val offset = textFieldState.selection.max.coerceIn(0, textLength)

        // カーソルの位置(Rect)を取得
        val cursorRect = layout.getCursorRect(offset)

        // TextField の現在の可視領域(スクロール込み)
        val visibleTop = scrollState.value.toFloat()
        val visibleBottom = scrollState.value + textFieldHeight

        // スクロールすべき位置を判断
        val targetScroll = when {
            // カーソルが下方向の可視領域から外れた場合
            cursorRect.bottom > visibleBottom - margin -> {
                val diff = cursorRect.bottom - (visibleBottom - margin)
                scrollState.value + diff
            }

            // カーソルが上方向の可視領域から外れた場合
            cursorRect.top < visibleTop + margin -> {
                val diff = (visibleTop + margin) - cursorRect.top
                scrollState.value - diff
            }

            // すでに可視範囲内 → スクロール不要
            else -> null
        }

        // スクロールが必要な場合、アニメーション付きで移動
        if (targetScroll != null) {
            coroutineScope.launch {
                scrollState.animateScrollTo(
                    targetScroll.toInt().coerceIn(0, scrollState.maxValue)
                )
            }
        }
    }

    BasicTextField(
        state = textFieldState,
        scrollState = scrollState,
        onTextLayout = { latestLayout.value = it },
        modifier = modifier
            .onSizeChanged { textFieldHeight = it.height }
        ...
}

実装のポイント

  • getCursorRect()でカーソル位置を取得
  • カーソルが可視領域に入っているかを常にチェック
  • 見えていない場合だけスクロールを移動
  • 上方向・下方向のどちらにも対応し、安定性が高い

適用結果

以下は、テキストがTextFieldの高さを超えても
カーソルが常に画面内に維持される動作例です。

6. まとめ

Jetpack ComposeのBasicTextFieldは、
複数行入力においてカーソルの可視性維持やIME対応 を標準で提供していないため、
行数が増えた瞬間に問題が発生しやすい仕組みになっています。

本記事では、

  • なぜカーソルがキーボードに隠れてしまうのか
  • TextFieldが抱える構造的な制限
  • カーソル位置ベースでスクロール制御が必要な理由
  • そして、それを解決するCustomTextFieldの実装

について解説しました。

今回のアプローチには次のようなメリットがあります。

  • どのような状況でも カーソルが常に画面内に見える
  • キーボードの表示・非表示に左右されない安定した入力体験
  • Composeの標準APIだけで実装可能
  • 他の入力画面にも簡単に再利用できる

複数行入力を扱うCompose画面では、
このような カーソル可視性を維持する仕組み が欠かせません。
同じ問題に悩んでいる方の参考になれば幸いです

Discussion