LINEヤフー Tech Blog

LINEヤフー株式会社のサービスを支える、技術・開発文化を発信しています。

Yahoo!フリマ Android開発チームのJetpack Compose活用事例(BasicTextField編)

こんにちは。Yahoo!フリマのAndroid開発を担当している坂井です。

私の開発チームでは、最近新しい画面を作る際にJetpack Compose(以下Compose)を用いて開発を行っています。昨年リリースされたグッズ交換機能は、すべての画面をComposeで作成しています。この記事では、これらの開発時に苦戦したテキストフィールド周りについての実装に関しての事例を紹介します。ComposeのBasicTextFieldはプロパティが豊富で柔軟なテキスト入力フィールドですが、その柔軟性ゆえに正しく動作するように実装するには癖があります。今回の開発でも、主にテキストフィールドとキーボードとの連携に苦戦しました。この記事を通して実装時に直面した課題に対してどういう対応を行ったかを「グッズ交換の募集フォーム」を例にとって詳しく説明します。

要件

グッズ交換の募集フォームにおけるテキストフィールドにはいくつかの要件があり、これらを満たすテキストフィールドを作成する必要がありました。要件としては以下の4つがあります。

  1. 入力された文字に合わせてテキストフィールドが広くなっていくこと
  2. 文字が入力された時に、画面内に収まっていること(キーボードにも被らないこと)
  3. 画面がスクロールされた時にキーボードを隠すこと
  4. 改行を連続で入力した時に、フォーカスが外れないようにすること

対象コンポーネント

↑ 赤枠で囲っている部分について取り上げていきます!

詳細仕様

以下は、Composeの2024.05.00のBOMを用いて実装した、グッズ交換の募集フォームのコードです。

fun BarterFormScreen(
    ...
) {
    Column(

    ) {
        BarterName() // 募集名
        BarterContent() // 募集内容
        BarterOfferComment() // 補足コメント ← 今回扱うのはこちら
        ...
    }
 }

fun BarterOfferComment(
    
) {
    SectionLabel() // ラベル
    BaseLargeTextField() // テキストフィールド 
}

@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class)
@Composable
fun BaseLargeTextField() {
    BasicTextField()
}

BarterFormScreenには、募集名、募集内容、補足コメントなど複数の要素が含まれています。今回は「補足コメント」のテキストフィールドにフォーカスして説明します。

実装について

3通りの実装を試したのでそれぞれご紹介します。

最初に行った実装(BAD Pattern)

こちらの要件を満たすために、最初は以下のような実装を行いました。(あまり関係のない部分は省略してあります)

fun BarterFormScreen(
    ...
) {
    val scrollState = rememberScrollState()
    var isEnabledHideKeyboard by remember {
        mutableStateOf(false)
    }
    LaunchedEffect(scrollState.isScrollInProgress) {
        if (scrollState.isScrollInProgress) {
            view.hideKeyboard() // スクロールを検知して、キーボードを閉じる
        }
    }
    Column(
        modifier = Modifier
            .verticalScroll(scrollState)
    ) {
        BarterName() // 募集名
        BarterContent() // 募集内容
        BarterOfferComment()
        ...
    }
}

fun BarterOfferComment() {
    SectionLabel() // ラベル
    BaseLargeTextField() // テキストフィールド 
}

@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class)
@Composable
fun BaseLargeTextField() {
    val coroutineScope = rememberCoroutineScope()
    val requester = remember { BringIntoViewRequester() }

    BasicTextField(
        ...,
        onValueChange = { text ->
            coroutineScope.launch {
                requester.bringIntoView()
            }
        }
    )
}

実装について軽く説明すると

    onValueChange = { text ->
        coroutineScope.launch {
            requester.bringIntoView()
        }
    }

文字が入力された際に、BringIntoViewRequesterという指定した要素が、表示領域内に表示されるように画面のスクロール位置を調整してくれるメソッドを用いて、スクロール位置を自動で調整しており

    LaunchedEffect(scrollState.isScrollInProgress) {
        if (scrollState.isScrollInProgress) {
            view.hideKeyboard() // スクロールを検知して、キーボードを閉じる
        }
    }

rootのcolumnにscrollStateを与えることによって要素内でのスクロールを検知し、キーボードを自動で閉じる実装を行っています。

UI要素の高さが短いテキストフィールドであればこちらで問題なかったのですが、実装してみると特定の条件の時にいくつかの問題が発生してしまいました。

  1. テキストフィールドの上端、下端が画面外にある状態でフォーカスをすると下端にスクロールしてしまう(動作も不安定)
  2. 1.の 状態でカーソルを当てて文字を入力するとキーボードが閉じてしまう

1の原因に関しては、ComposeのBasicTextFieldで用いていたbringIntoView()が、view全体を表示するために対象のviewの下端にスクロールするという処理を行うため、縦に長いテキストフィールドでは強制的に下の方にスクロールしてしまう想定外の動作が発生していました。また、2についてはbringIntoView()によるスクロールが手動スクロールを検知する処理に引っかかってしまい、このため意図しない動作が発生していました。UI要素の高さが短いテキストフィールドであればbringIntoView()は便利ですが、今回のような要件のテキストフィールドを実装する時は、bringIntoViewに頼るのは厳しそうでした。

リリース期限の制約もあるので、以降は並行して解決策を模索していきました。

AndroidViewによる実装

次に、xmlで定義された従来のUIパーツをComposableでも使えるようにするAndroidViewを用いた実装を行いました。

@Composable
fun BaseLargeTextField() {
    AndroidView(
        modifier = Modifier.fillMaxWidth(),
        factory = { context ->
            val editTextField = View.inflate(
                context,
                R.layout.layout_form_description,
                null
            ) as TextInputEditText

            editTextField.apply {
                addTextChangedListener(
                    onTextChanged = { current, _, _, _ ->
                        // 文字入力がされた時の処理
                        ...
                    }
                )

                setOnFocusChangeListener { _, hasFocus ->
                    // focusが更新された時の処理
                    ...
                }

            }
        },
        update = { view ->
            view.apply {
                // viewが更新された時の処理
                ...
            }
        }
    )
}

この実装によって、先ほどのBad Patternの不具合も解消できました。ただし、この機能はWebViewなどComposeでの実装が難しい場合に使用されることが一般的で、あまり多用したい機能ではありません。そのため、並行してComposeで実装できないか模索することにしました。

独自実装

実際に実装したコードをテキストフィールドに絞って紹介していきます。(必要な箇所以外は省略しています)

fun BarterFormScreen(
    ...
) {
    val scrollState = rememberScrollState()
    var isEnabledHideKeyboard by remember {
        mutableStateOf(false)
    }
    LaunchedEffect(scrollState.isScrollInProgress) {
        if (scrollState.isScrollInProgress && isEnabledHideKeyboard) {
            view.hideKeyboard()
        }
    }
    Column(
        modifier = Modifier
            .verticalScroll(scrollState)
    ) {
        BarterName() // 募集名
        BarterContent() // 募集内容
        BarterOfferComment(
            parentScrollState = scrollState,
            commentOnBringIntoViewing = {
                isEnabledHideKeyboard = !it
            },
        )
        ...
    }
}

fun BarterOfferComment(
    parentScrollState: ScrollState,
    commentOnBringIntoViewing: (Boolean) -> Unit,
    ...
) {
    SectionLabel() // ラベル
    BaseLargeTextField(
        parentScrollState = parentScrollState,
        commentOnBringIntoViewing = commentOnBringIntoViewing,
    ) // テキストフィールド 
}

@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class)
@Composable
fun BaseLargeTextField(
    parentScrollState: ScrollState,
    onBringIntoViewing: (Boolean) -> Unit,
    ...
) {
    val coroutineScope = rememberCoroutineScope()

    val density = LocalDensity.current
    val activity = when (val context = LocalContext.current) {
        is Activity -> context
        is ContextWrapper -> context.baseContext as? Activity
        else -> null
    }
    var topTextHeight by remember { mutableStateOf(0) }
    var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
    val adjustHeight = 30 // 文字が見切れる時用の調整値

    BasicTextField(
        modifier = Modifier
            .onGloballyPositioned { coordinates ->
                // ルートビューの上端からのY座標を取得
                topTextHeight = coordinates.positionInRoot().y.toInt() + parentScrollState.value
            },
            .onFocusChanged {
                if (it.isFocused) {
                    // フォーカス時のスクロール誤検知防止用
                    onBringIntoViewing(true)
                    coroutineScope.launch {
                        delay(200)
                        onBringIntoViewing(false)
                    }
                }
            },
        onValueChange = { text ->
           if (value.text != text.text) {
                coroutineScope.launch {
                    onBringIntoViewing(true) // 親viewでキーボードを閉じないように
                    delay(100) // キーボードサジェストに対応
                    textLayoutResult?.let { layoutResult ->
                        // カーソルのBasicTextField内での相対位置を取得
                        val cursorLine = layoutResult.getLineForOffset(textFieldValue.selection.start)
                        val cursorYCoordinate =
                            layoutResult.getLineBottom(cursorLine).toInt()

                        if (activity == null) return@launch
                        // キーボードの高さを考慮した画面の高さを取得
                        val screenHeightRect = Rect()
                        val decorView: View = activity.window.decorView
                        decorView.getWindowVisibleDisplayFrame(screenHeightRect)
                        val screenHeight = screenHeightRect.bottom - screenHeightRect.top

                        // appbarの高さを取得
                        val appBarRect = Rect()
                        val rootView: View = decorView.rootView
                        rootView.getWindowVisibleDisplayFrame(appBarRect)
                        val appBarHeightDp  = appBarRect.top.toFloat().dp
                        val appBarHeight = with(density) { appBarHeightDp.roundToPx() }

                        if (topTextHeight + cursorYCoordinate < parentScrollState.value + appBarHeight - adjustHeight) {
                            //screenの上に隠れた時
                            parentScrollState.scrollBy(
                                (topTextHeight + cursorYCoordinate - parentScrollState.value - appBarHeight - adjustHeight).toFloat()
                            )
                        } else if (topTextHeight + cursorYCoordinate + adjustHeight > parentScrollState.value + screenHeight
                        ) {
                            //screenの下に隠れた時
                            parentScrollState.scrollBy(
                                (topTextHeight + cursorYCoordinate + adjustHeight - parentScrollState.value - screenHeight).toFloat()
                            )
                        } else {
                            // screen内に収まってる時は何もしない
                        }
                    }
                    onBringIntoViewing(false) // 親viewでキーボードを閉じないようにしていたのを解除する
                }
            }
        },
        onTextLayout = { layoutResult ->
            textLayoutResult = layoutResult
        }
    )
}

ポイントとなる箇所を、いくつかご紹介します。

1. スクロールを検知するロジックの修正

LaunchedEffect(scrollState.isScrollInProgress) {
    if (scrollState.isScrollInProgress && isEnabledHideKeyboard) {
        view.hideKeyboard()
    }
}

ユーザーの操作によってスクロールした時と、テキストフィールド側の自動スクロールを区別するために、isEnabledHideKeyboardという変数を新たに追加しました。

onBringIntoViewing(Boolean)

をスクロール処理の間に差し込むことによって、自動スクロールの時はキーボードを閉じないように制御しています。

2. フォーカスが画面外に出ているかどうかの判定

  • cursorYCoordinate(カーソルのBasicTextField上での相対座標)
  • topTextHeight(BasicTextFieldより上に存在する要素の高さ)
  • parentScrollState.value(親viewのスクロール量)
  • appbarHeight(appbarの高さ)
  • screenHeight(キーボードの高さを考慮した画面の高さ)

この5種類の値を用いてカーソルの位置の判定を行っています。

if (topTextHeight + cursorYCoordinate < parentScrollState.value + appbarHeight - adjustHeight) {
    //screenの上に隠れた時
} else if (topTextHeight + cursorYCoordinate + adjustHeight > parentScrollState.value + screenHeight
) {
    //screenの下に隠れた時
} else {
    // screen内に収まってる時
}

で3つの状態を判定し、それに応じて親viewのscrollStateを用いてScrollを行っています。

*たまに文字が見切れてしまうことがあることがあるため、調整値としてadjustHeightを入れています

3. フォーカス時にBasicTextFieldが内部で行っているbringIntoViewを誤検知しないように

    .onFocusChanged {
        if (it.isFocused) {
            // フォーカス時のスクロール誤検知防止用
            onBringIntoViewing(true)
            coroutineScope.launch {
                delay(200)
                onBringIntoViewing(false)
            }
        }
    }

フォーカス時はdalayを用いて、フォーカス時のスクロールが完了するまでの間onBringIntoViewingを行うことにより、キーボードが隠れないように制御しています。

4. キーボードのサジェスト対応

    delay(100) // キーボードサジェストに対応

文字を入力した時に出現するサジェストの枠の高さも計算に含めないとサジェストが出る時の入力が埋もれてしまうので、こちらもサジェストが表示されるまでdalayで待ちつつ、後続の処理が実行されるようにしています。

機能整理

1つ目のBadPatternから始まり、並行して2つの方法で実装を行った後にMiroで現状の整理を行いました。(要件に対して実現できているか/できていないか)

現状整理

AndroidViewに関しては実装コストも低く、動作も安定していましたが改行を繰り返した時になぜかキーボードが閉じてしまう難点がありました。Compose化を推進しているという観点でもAndroidViewを使うのはマイナスポイントでした。独自実装に関しては、要件も満たしつつなぜかキーボードが閉じてしまうポイントもクリアしています。しかし、独自で制御している部分が多いため不安が残ります。どちらの実装を採用するかチームで直前まで悩みましたが、Compose化を推進していることに加え、グッズ交換の機能自体が比較的新しいため、多少のリスクを許容できる点から独自実装の方を採用しました。

*リリースまでのスケジュールがタイトな都合上、Yahoo!フリマAndroidチームではよくこの方法で方針を決定しています。

おわりに

今回は、Yahoo!フリマAndroidでのテキストフォームについての実装方法を、グッズ交換の募集フォームを例にご紹介させていただきました。Composeは使ってみるとかなり便利ですが、今回のテキストフィールドのように落とし穴やハマりやすい部分がまだ多い印象を受けました。実務で利用するという観点では、まだまだAndroidViewに比べると発展途上なところもありますが、それを上回るメリットがComposeにはあると思っています。(実際、Yahoo!フリマでもComposeをメインにしてから開発速度はかなり向上しており積極的に利用しています!)この記事が、Composeでテキストフィールドを実装する際に、少しでも皆さまのお役に立てれば幸いです。

Yahoo!フリマAndroidチームでは、現在中途社員を募集しています!興味のある方はぜひ公式採用ページから詳細をご確認ください。一緒にYahoo!フリマをより良いサービスにしていきましょう!

採用ページはこちらからご覧いただけます: Androidエンジニア / Yahoo!オークション・Yahoo!フリマ|LINEヤフー株式会社

皆さんのご応募、お待ちしております!

参考文献