5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Android強化月間 - Androidアプリ開発の知見を共有しよう -

2023 Android の IME (キーボード) の作成

Last updated at Posted at 2023-09-26

作成したもの

Play store

Github

参考にした記事

Layout

Screenshot 2023-09-25 at 5.02.56 AM.png

ConstraintLayout の chainStyle に spread を選択する事で layout のネストを少なくした。

キーマップ

キーマップこちらの記事 を参考にして作成した。上下左右フリック文字を取得出来るようにした

sealed class TenKeyInfo{

    object Null : TenKeyInfo()

    abstract class TenKeyTapFlickInfo : TenKeyInfo() {
        abstract val tap: Char
        abstract val flickLeft: Char
        abstract val flickTop: Char
        abstract val flickRight: Char
        abstract val flickBottom: Char
    }

    object KeyAJapanese : TenKeyTapFlickInfo() {
        override val tap: Char
            get() = 'あ'
        override val flickLeft: Char
            get() = 'い'
        override val flickTop: Char
            get() = 'う'
        override val flickRight: Char
            get() = 'え'
        override val flickBottom: Char
            get() = 'お'
    }

    object KeyKAJapanese : TenKeyTapFlickInfo() {
        override val tap: Char
            get() = 'か'
        override val flickLeft: Char
            get() = 'き'
        override val flickTop: Char
            get() = 'く'
        override val flickRight: Char
            get() = 'け'
        override val flickBottom: Char
            get() = 'こ'
    }

    //以下略
}

文字入力に使用したロジック

1. setOnTouchListener を使用しタップやフリックを検知する

/** mainView.keyboardView.keySmallLetter is AppCompatImageButton, others are AppCompatButton **/
val keyList = listOf<Any>(
                    mainView.keyboardView.key1,
                    mainView.keyboardView.key2,
                    //以下略
                )

keyList.forEach {
            if (it is AppCompatButton){
                it.setOnTouchListener { v, event ->
                    when(event.action and MotionEvent.ACTION_MASK){
                        MotionEvent.ACTION_DOWN ->{
                            firstXPoint = event.rawX
                            firstYPoint = event.rawY
                            currentTenKeyId = v.id
                            return@setOnTouchListener false
                        }
                        MotionEvent.ACTION_UP ->{
                            val finalX = event.rawX
                            val finalY = event.rawY
                            val distanceX = (finalX - firstXPoint)
                            val distanceY = (finalY - firstYPoint)

                            val keyInfoJapanese = tenKeyMap.getTenKeyInfoJapanese(currentTenKeyId)
                                    if (abs(distanceX) < 100 && abs(distanceY) < 100){
                                        if (keyInfoJapanese is TenKeyInfo.TenKeyTapFlickInfo){
                                            /** Tap タップ **/
                                        }
                                        currentTenKeyId = 0
                                        return@setOnTouchListener false
                                    }

                                    if (abs(distanceX) > abs(distanceY)) {
                                        if (firstXPoint < finalX) {
                                            if (keyInfoJapanese is TenKeyInfo.TenKeyTapFlickInfo){
                                                /** Flick Right 右フリック **/
                                            }
                                        }else {
                                            if (keyInfoJapanese is TenKeyInfo.TenKeyTapFlickInfo){
                                                /** Flick Left 左フリック **/
                                            }
                                        }
                                    }else {
                                        if (firstYPoint < finalY) {
                                            if (keyInfoJapanese is TenKeyInfo.TenKeyTapFlickInfo){
                                                /** Flick Down 下フリック **/
                                            }
                                        }else{
                                            if (keyInfoJapanese is TenKeyInfo.TenKeyTapFlickInfo){
                                                /** Flick Up 上フリック **/
                                            }
                                        }
                                    }
                                    currentTenKeyId = 0
                                    return@setOnTouchListener false
                        }
                        MotionEvent.ACTION_MOVE ->{
                            val finalX = event.rawX
                            val finalY = event.rawY
                            val distanceX = (finalX - firstXPoint)
                            val distanceY = (finalY - firstYPoint)

                            if (abs(distanceX) < 100 && abs(distanceY) < 100){
                                /** Tap タップ **/
                                return@setOnTouchListener false
                            }
                            if (abs(distanceX) > abs(distanceY)) {
                                if (firstXPoint < finalX) {
                                    /** Flick Right 右フリック **/
                                }else{
                                     /** Flick Left 左フリック **/
                                }
                            }else {
                                if (firstYPoint < finalY){
                                   /** Flick Down 下フリック **/
                                }else{
                                   /** Flick Up 上フリック **/
                                }
                            }
                            return@setOnTouchListener false
                        }
                        else -> return@setOnTouchListener true
                    }
                }
                it.setOnLongClickListener { v ->
                    /** Display PopupWindows around long clicked button **/
                    /** ロングクリックした場合、PopupWindows をボタンの周りに表示する **/
                    false
                }
            }
        }

2. collectLatest を使用する

_inputString という名前の MutableStateFlow を用意して onCreateInputView() 内で collectLatestする。InputConnection.setComposingText(CharSequence text, int newCursorPosition) で文字を入力する

private val _inputString = MutableStateFlow("")

override fun onCreateInputView(): View? {
        val ctx = ContextThemeWrapper(this, R.style.Theme_MarkdownKeyboard)
        mainLayoutBinding = MainLayoutBinding.inflate(LayoutInflater.from(ctx))
        return mainLayoutBinding?.root.apply {
            mainLayoutBinding?.let { mainView ->
                scope.launch {
                    withContext(imeIoDispatcher){
                        _inputString.asStateFlow().collectLatest { inputString ->
                            if (inputString.isNotBlank()) { 
                                /** SpannableString で入力された文字の周りの色を設定する **/
                                val spannableString = SpannableString(inputString)
                                spannableString.apply {
                                    setSpan(BackgroundColorSpan(getColor(R.color.green)),0,inputString.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
                                }
                                /** SpannableString を setComposingText でセットする **/
                                currentInputConnection?.setComposingText(spannableString,1)
                            } else {
                                /** inputString が Empty 場合の処理 **/
                            }

                        }
                    }
                }
            }
        }
    }

カーソルの位置を取得する

1. onUpdateCursorInfo を override する

onStartInput で InputConnection.requestCursorUpdates(int cursorUpdateMode) を呼び出す。

CURSOR_UPDATE_MONITOR のフラッグを使用してカーソルが移動する度に onUpdateCursorAnchorInfo が呼ばれる。

private var mComposingTextPosition = -1
private var selectionEndtPosition = -1

   override fun onStartInput(attribute: EditorInfo?, restarting: Boolean) {
        super.onStartInput(attribute, restarting)
        currentInputConnection.requestCursorUpdates(InputConnection.CURSOR_UPDATE_MONITOR)
    }

override fun onUpdateCursorAnchorInfo(cursorAnchorInfo: CursorAnchorInfo?) {
        super.onUpdateCursorAnchorInfo(cursorAnchorInfo)
        cursorAnchorInfo?.let { info ->
            selectionEndtPosition = info.selectionEnd
            mComposingTextPosition = info.composingTextStart
        }
}

2. ExtractedText を使用する

onUpdateCursorAnchorInfo をサポートしていない View を発見した。(2023 年 Android FireFox アプリ内の WebView での Google 検索)

その為、ExtractedText の startOffset + selectionEnd でカーソルの位置を取得する。

Enter Key の処理

Facebook の Messager や Message 等、 Enter Key で改行や独自の処理を行いたい場合がある。

1. InputType に対応する Sealed Class を作成する

sealed class InputTypeForIME{
    object None: InputTypeForIME()
    object Text: InputTypeForIME()
    object TextAutoComplete: InputTypeForIME()
    object TextMultiLine: InputTypeForIME()
    object TextImeMultiLine: InputTypeForIME()
    object TextWebEditText: InputTypeForIME()
    object TextSearchView: InputTypeForIME()
    object Number: InputTypeForIME()
    object Phone: InputTypeForIME()
    object Date: InputTypeForIME()
    /** 以下省略 **/
}

2. 作成した Sealed class を返す method を作成する

InputType はこちらのページを参考にした

fun getCurrentInputTypeForIME(
    inputType: Int
): InputTypeForIME{
    return when(inputType and InputType.TYPE_MASK_CLASS){
        InputType.TYPE_CLASS_TEXT ->{
            when(inputType){
                InputType.TYPE_TEXT_VARIATION_NORMAL -> InputTypeForIME.Text
                InputType.TYPE_TEXT_FLAG_MULTI_LINE -> InputTypeForIME.TextMultiLine
                /**
                 *  180225 : Twitter Tweet & Messenger
                 *  147457 : Facebook Messenger
                 * */
                InputType.TYPE_TEXT_FLAG_IME_MULTI_LINE,180225, 147457 -> InputTypeForIME.TextImeMultiLine
                InputType.TYPE_TEXT_VARIATION_PASSWORD, 129, 225,16545, 209 -> InputTypeForIME.TextPassword
                InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD -> InputTypeForIME.TextVisiblePassword
                /**
                 *  524465 : Twitter (X) Search View
                 * **/
                524465 -> InputTypeForIME.TextSearchView
                /** 以下省略 **/
                else -> InputTypeForIME.Text
            }
        }
        InputType.TYPE_CLASS_NUMBER ->{
            when(inputType){
                InputType.TYPE_NUMBER_VARIATION_NORMAL -> InputTypeForIME.Number
                InputType.TYPE_NUMBER_FLAG_SIGNED -> InputTypeForIME.NumberSigned
                InputType.TYPE_NUMBER_FLAG_DECIMAL -> InputTypeForIME.NumberDecimal
                InputType.TYPE_NUMBER_VARIATION_PASSWORD -> InputTypeForIME.NumberPassword
                180225 -> InputTypeForIME.TextImeMultiLine
                else -> InputTypeForIME.Number
            }
        }
        InputType.TYPE_CLASS_PHONE -> {
            when(inputType){
                180225 -> InputTypeForIME.TextImeMultiLine
                else -> InputTypeForIME.Phone
            }
        }
        InputType.TYPE_CLASS_DATETIME ->{
            when(inputType){
                InputType.TYPE_DATETIME_VARIATION_NORMAL -> InputTypeForIME.Datetime
                InputType.TYPE_DATETIME_VARIATION_DATE -> InputTypeForIME.Date
                InputType.TYPE_DATETIME_VARIATION_TIME -> InputTypeForIME.Time
                180225 -> InputTypeForIME.TextImeMultiLine
                else -> InputTypeForIME.Datetime
            }
        }
        InputType.TYPE_NULL -> InputTypeForIME.None
        else -> InputTypeForIME.None
    }
}

3. onStartInput で作成した Sealed Class を EditorInfo.inputType を引数にして取得する

EditorInfo.TYPE_NULL の場合 raw key event の処理をする。引用

private var currentInputType: InputTypeForIME = InputTypeForIME.Text

override fun onStartInput(attribute: EditorInfo?, restarting: Boolean) {
        super.onStartInput(attribute, restarting)

        attribute?.apply {
            currentInputType = getCurrentInputTypeForIME(inputType)
            when(currentInputType){
                InputTypeForIME.Text,
                InputTypeForIME.TextAutoComplete,
                /** 以下省略 **/
                -> {
                    /** layout type Japanese **/
                }

                InputTypeForIME.TextEditTextInBookingTDBank,
                InputTypeForIME.TextUri,
                InputTypeForIME.TextPostalAddress,
                InputTypeForIME.TextEmailAddress,
                InputTypeForIME.TextWebEmailAddress,
                InputTypeForIME.TextPassword,
                InputTypeForIME.TextVisiblePassword,
                InputTypeForIME.TextWebPassword,
                ->{
                    /** layout type English **/
                }

                InputTypeForIME.None, InputTypeForIME.TextNotCursorUpdate ->{
                    /** text type null **/
                }

                InputTypeForIME.Number,
                InputTypeForIME.NumberDecimal,
                InputTypeForIME.NumberPassword,
                InputTypeForIME.NumberSigned,
                InputTypeForIME.Phone,
                InputTypeForIME.Date,
                InputTypeForIME.Datetime,
                InputTypeForIME.Time, -> {
                    /** layout type Number **/
                }
            }
        }
    }

4. Enter Key の処理

private fun setEnterKeyPress(){
        when(currentInputType){
            InputTypeForIME.TextMultiLine,
            InputTypeForIME.TextImeMultiLine ->{
                currentInputConnection?.commitText("\n",1)
            }

            InputTypeForIME.None,
            InputTypeForIME.Text,
            /** 以下省略 **/
            -> {
                currentInputConnection?.apply {
                    sendKeyEvent(
                        KeyEvent(
                            KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER
                        )
                    )
                    sendKeyEvent(
                        KeyEvent(
                            KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER
                        )
                    )
                }
            }

            InputTypeForIME.Number,
            InputTypeForIME.NumberPassword,
            InputTypeForIME.Phone,
            InputTypeForIME.Date, -> {
                currentInputConnection?.performEditorAction(EditorInfo.IME_ACTION_DONE)
            }

            InputTypeForIME.TextSearchView ->{
                currentInputConnection?.performEditorAction(EditorInfo.IME_ACTION_SEARCH)
            }

        }
    }

その他

Google Chrome 等、検索アプリで英語入力すると自動で候補が入力される。
例えば you と打つと youtube となる。
この状態で 新たに currentInputConnection.setComposingText のメソッドを使用すると currentInputConnection.finishComposingText() の処理がされてしまう。
その為 onUpdateCursorAnchorInfo で ComposeingText が null の場合、_inputString を初期化する処置をとった。

suggestion.gif

override fun onUpdateCursorAnchorInfo(cursorAnchorInfo: CursorAnchorInfo?) {
        super.onUpdateCursorAnchorInfo(cursorAnchorInfo)
        cursorAnchorInfo?.let { info ->
            if (info.composingText == null && _inputString.value.isNotEmpty()){
                if (currentInputType == InputTypeForIME.TextWebSearchView || currentInputType == InputTypeForIME.TextWebSearchViewFireFox) {
                    _inputString.update { EMPTY_STRING }
                }
            }
        }
    }

最後に

かな漢字変換に OpenWnn を使用した。最終的には独自のかな漢字変換器を実装して搭載したい。

5
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?