🗝️

JetpackCompose よくあるSMS入力フォームを作ってみた

2021/11/18に公開
2

こういうやつ。

サンプルコード

@Composable
fun SmsContentWidget(
    onValueChange: (String, Int) -> Unit,
    sendPhoneValue: String,
    backPressed: () -> Unit
) {
    val smsValueMap: MutableMap<Int, String> = mutableMapOf()
    val latestPosition = remember { mutableStateOf(-1) }

    Box(
        modifier = Modifier.fillMaxWidth()
    ) {
        Text(
            text = stringResource(id = R.string.contentTitleSms),
        )
    }

    Spacer(
        modifier = Modifier
            .height(8.dp)
    )

    Text(
        text = sendPhoneValue
                + stringResource(id = R.string.contentSms),
    )

    Spacer(
        modifier = Modifier
            .height(16.dp)
    )

    SmsAuthTextField(
        latestPosition = latestPosition.value,
        numMap = smsValueMap,
        digit = 6,
        onNumChange = onValueChange
    ) {
        latestPosition.value = it
    }

    Spacer(
        modifier = Modifier
            .height(16.dp)
    )

    Box(
        modifier = Modifier.fillMaxWidth(),
        contentAlignment = Alignment.BottomEnd
    ) {
    // 電話番号再送信ボタン
        SendAgainButton {
            inputEntityを初期化する処理
            latestPosition.value = -1
            phoneNumberAgainAction.send()
        }
    }
}

@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun SmsAuthTextField(
    latestPosition: Int,
    numMap: Map<Int, String>,
    digit: Int,
    backPressed: () -> Unit,
    onNumChange: (String, Int) -> Unit,
    updateLatestPosition: (Int) -> Unit
) {
    val keyboardController = LocalSoftwareKeyboardController.current
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.SpaceEvenly
    ) {
        repeat(digit) {
            SmsAuthTextFieldUnit(
                onDone = {
                    keyboardController?.hide()
                },
                position = it,
                latestPosition = latestPosition,
                num = numMap[it] ?: "",
                onNumChange = { s, i ->
                    updateLatestPosition(
                        if (s.isBlank()) i - 2
                        else i
                    )
                    if (i == digit - 1) keyboardController?.hide()
                    onNumChange(s, i)
                    Timber.tag("latestPosition").d("$latestPosition")
                    Timber.tag("numMap").d("$numMap")
                }
            )
        }
    }
}

@ExperimentalComposeUiApi
@Composable
private fun SmsAuthTextFieldUnit(
    onDone: () -> Unit,
    position: Int,
    latestPosition: Int,
    num: String,
    onNumChange: (String, Int) -> Unit,
    backPressed: () -> Unit
) {
    val hasFocus = latestPosition == position - 1
    val focusRequester = remember { FocusRequester() }
    SideEffect {
        if (hasFocus) focusRequester.requestFocus()
    }
    Box(
        contentAlignment = Alignment.Center
    ) {
        TextField(
            modifier = Modifier
                .size(48.dp)
                .focusRequester(focusRequester)
                .onKeyEvent {
                    when (it.nativeKeyEvent.keyCode) {
                        KEYCODE_DEL -> onNumChange(" ".trim(), position)
                        KEYCODE_BACK -> backPressed()
                    }
                    true
                },
            value = num.takeIf { hasFocus } ?: " ",
            onValueChange = {
                    onNumChange(it.trim(), position)
            },
            keyboardOptions = KeyboardOptions(
                keyboardType = KeyboardType.Number,
                imeAction = ImeAction.Done
            ),
            keyboardActions = KeyboardActions(
                onDone = {
                    onDone()
                }
            ),
            visualTransformation = PasswordVisualTransformation(),
            singleLine = true,
            maxLines = 1
        )
        if (num.isBlank() && !hasFocus) Spacer(
            modifier = Modifier
                .background(Colors.textField)
                .size(8.dp)
        )
    }
}

ハマりポイント

  1. 入力したら次にフォーカス、削除したら前にフォーカスするのが大変だった。
  2. カーソルが左側にあっても消せるようにするのが大変だった。
  3. 変な値が入らないようにするのが大変だった。

解決ポイント

  • 入力したら次にフォーカス、削除したら前にフォーカスするのが大変だった。
    • 入力している値を監視し、値が入っていれば次にフォーカス
    • 値がなければ手前にフォーカス
  • カーソルが左側にあっても消せるようにするのが大変だった。
TextField(
            modifier = Modifier
                .size(48.dp)
                .focusRequester(focusRequester)
                .onKeyEvent {
                    if (it.nativeKeyEvent.keyCode == KEYCODE_DEL) {
                        onNumChange(" ".trim(), position)
                    }
                    true
                },

ここですね。

Modifierに対してonKeyEventをくっつけることができるので、

キーを押した時に指定のComposeに対してイベントを発火させることができる。

なんか色々書いてあったけどどれも当てにならなくて、

結局自分で探った結果

nativeKeyEventでスマホのキーを拾うことができて、

それのkeyCodeを拾って指定することでやりたい動きを実現することができた。

  • 変な値が入らないようにするのが大変だった。
    • これはひたすらデバッグで止めて確認しただけ()

実は大変なバグがある

別にこのコードに限ったことではないのですが、

JetpackComposeのキーボードタイプ指定がバグってて、

フォーカスが当たるたびに一瞬元の言語のキーボードがチラつきます......

https://chetangupta.net/keyboard-switch-bug/?s=09

この記事でも取り上げられGoogleIssueTrackerにも挙げられているようですが、

今のところ解決策はなさそうです......

何か知ってる人いたら教えてください(泣)

更なるハマりポイントがあった

onKeyEvent

こちらですが完全に端末のキー操作をハックしてしまうようで、

it.nativeKeyEvent.keyCode == KEYCODE_BACK -> backPressed()

こうやってあげないと戻るキーで前画面に戻れなくなってしまいました.......

もしかしたら他のキーも聞かなくなっている可能性があるので、

onKeyEventは気をつけて使わないと危ないですね;;;;;;

参考記事

https://developer.android.com/reference/android/view/KeyEvent

https://twitter.com/ychescale9/status/1372220313962905602

https://chetangupta.net/keyboard-switch-bug/?s=09

本当はもっと参考記事が色々あったのですが、

chomeが落ちてタブにしか残ってなかったので全部消えました......

Discussion

Ryuji OdaRyuji Oda

これやばいですね、気をつけんと、、w

こちらですが完全に端末のキー操作をハックしてしまうようで、

it.nativeKeyEvent.keyCode == KEYCODE_BACK -> backPressed()
あっぷる中谷あっぷる中谷

ヤバ過ぎて震えましたね😇
指定したキーにイベントを持たせるのかと思ったら全制御奪っちゃうとか何事ーーーーーーw