AndroidX時代のIME作成 (1: 簡単なキーボードの作成)

相変わらず日本語圏でのIME作成に関する記事が少なめなので、技術者ではなくなったものの発信は続けていきます。

今回は[公式ドキュメント](https://developer.android.com/guide/topics/text/creating-input-method?hl=ja)にも紹介されているため今までお世話になってきた方も多いであろう、KeyboardおよびKeyboardViewの代替を試みるものになっています。



## なにがあったか

API Level 29から**Keyboardに関するクラスが一挙に非推奨となりました。**

実際これらのクラスのオンラインドキュメントを読むと別手法を取るよう勧告されています。



## どうするか

新しい仕組みが用意されているわけでもないので、いちから自作します。



### Viewまわり

まずは res/layout/keyboard.xml にViewレイアウトを作成。Google日本語入力の電話配列を意識して今回はGridLayoutを採用。

```xml
<GridLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:columnCount="5">

    <Button
        android:id="@+id/key_1"
        android:text="1"
        style="@style/Key"/>

    <Button
        android:id="@+id/key_2"
        android:text="2"
        style="@style/Key"/>

    <Button
        android:id="@+id/key_3"
        android:text="3"
        style="@style/Key"/>

    <Button
        android:id="@+id/key_4"
        android:text="4"
        style="@style/Key"/>

    <Button
        android:id="@+id/key_5"
        android:text="5"
        style="@style/Key"/>

    <Button
        android:id="@+id/key_6"
        android:text="6"
        style="@style/Key"/>

    <Button
        android:id="@+id/key_7"
        android:text="7"
        style="@style/Key"/>

    <Button
        android:id="@+id/key_8"
        android:text="8"
        style="@style/Key"/>

    <Button
        android:id="@+id/key_9"
        android:text="9"
        style="@style/Key"/>

    <Button
        android:id="@+id/key_10"
        android:text="10"
        style="@style/Key"/>

    <Button
        android:id="@+id/key_11"
        android:text="11"
        style="@style/Key"/>

    <Button
        android:id="@+id/key_12"
        android:text="12"
        style="@style/Key"/>

    <Button
        android:id="@+id/key_13"
        android:text="13"
        style="@style/Key"/>

    <Button
        android:id="@+id/key_14"
        android:text="14"
        style="@style/Key"/>

    <Button
        android:id="@+id/key_15"
        android:text="15"
        style="@style/Key"/>

    <Button
        android:id="@+id/key_16"
        android:text="16"
        style="@style/Key"/>

    <Button
        android:id="@+id/key_17"
        android:text="17"
        style="@style/Key"/>

    <Button
        android:id="@+id/key_18"
        android:text="18"
        style="@style/Key"/>

    <Button
        android:id="@+id/key_19"
        android:text="19"
        style="@style/Key"/>

    <Button
        android:id="@+id/key_20"
        android:text="20"
        style="@style/Key"/>

</GridLayout>
```

キーの代替となるButtonの共通属性は res/values/styles.xml にて定義。
ミソは`android:minWidth`と`android:layout_columnWeight`。こいつらを指定することで見覚えあるアレに近づきます。

```xml
<resources>

    <style name="Key">
        <item name="android:layout_width">wrap_content</item>
        <item name="android:layout_height">wrap_content</item>
        <item name="android:textColor">#000</item>
        <item name="android:textAlignment">center</item>
        <item name="android:minWidth">48dip</item>
        <item name="android:layout_columnWeight">1</item>
    </style>

</resources>
```



### キーマップ

つづいてキーマップの作成。
キー情報は以前使っていたものを流用。修飾キーを使わない人はこんなことしなくてOK。

```kotlin
sealed class KeyInfo {
    object Null : KeyInfo()
    
    abstract class AsciiKeyInfo : KeyInfo() {
        abstract val char: Char
        abstract val code: Int
    }
    
    object Num0 : AsciiKeyInfo() {
        override val char = '0'
        override val code = KeyEvent.KEYCODE_0
    }
    
    object Num1 : AsciiKeyInfo() {
        override val char = '1'
        override val code = KeyEvent.KEYCODE_1
    }
    
    // 以下略
}
```

これらキーマップの構成要素をキーボードの各Buttonに対応させます。内部に`android:id`をキーとしたハッシュマップを用意しておき、ここから随時取り出す形に。

```kotlin
interface KeymapHolder {
    val keys: Set<Int>
    fun getKeyInfo(indexId: Int): KeyInfo
}

class Keymap : KeymapHolder {
    private val list: Map<Int, KeyInfo> = mapOf(
        R.id.key_1 to KeyInfo.Num1,
        R.id.key_2 to KeyInfo.Num2,
        R.id.key_3 to KeyInfo.Num3,
        R.id.key_4 to KeyInfo.Num4,
        // 中略
        R.id.key_19 to KeyInfo.Num9,
        R.id.key_20 to KeyInfo.Num0
    )

    override val keys = list.keys

    override fun getKeyInfo(indexId: Int): KeyInfo {
        return list.getOrDefault(indexId, KeyInfo.Null)
    }
}
```



### その他

これらを使ったIMEが以下の通り。

```kotlin
class CustomIME : InputMethodService(), View.OnTouchListener {
    private val keymap: KeymapHolder = Keymap()
    private var currentKeyId = 0

    @SuppressLint("InflateParams")
    override fun onCreateInputView(): View {
        return layoutInflater.inflate(R.layout.keyboard, null).also { view ->
            keymap.keys.map { id ->
                view.findViewById<Button>(id).also { it.setOnTouchListener(this) }
            }
        }
    }

    @SuppressLint("ClickableViewAccessibility")
    override fun onTouch(v: View?, event: MotionEvent?): Boolean {
        return if (v is Button && event is MotionEvent) {
            when (event.action and MotionEvent.ACTION_MASK) {
                MotionEvent.ACTION_DOWN -> {
                    // 押下したボタンのandroid:idを保持
                    currentKeyId = v.id
                    false
                }
                MotionEvent.ACTION_UP -> {
                    // 押下ボタンに基づいた文字を入力
                    sendKeyEvent(currentKeyId)
                    currentKeyId = 0
                    false
                }
                // タッチ位置が変わったり、複数指でタッチしても
                // 見た目の変化が発生しないようにする
                else -> true
            }
        } else {
            true
        }
    }

    // キー入力。本来はもっとごちゃごちゃするので簡略化。
    private fun sendKeyEvent(id: Int) {
        if (id !in keymap.keys) {
            return
        }

        val keyInfo = keymap.getKeyInfo(id)
        if (keyInfo is KeyInfo.AsciiKeyInfo) {
            sendKeyChar(keyInfo.char)
        }
    }
}
```

AndroidManifest.xml はこのようにしておきます。

```xml
<manifest
    xmlns:android="http://schemas.android.com/apk/res/android"
    package="something.domain.of.keyboard">

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">

        <!--
        InputMethodServiceのサブクラスをnameに指定
        -->
        <service
            android:name=".CustomIME"
            android:label="@string/app_name"
            android:permission="android.permission.BIND_INPUT_METHOD">

            <meta-data
                android:name="android.view.im"
                android:resource="@xml/method"/>

            <intent-filter>
                <action android:name="android.view.InputMethod"/>
            </intent-filter>

        </service>
    </application>

</manifest>
```

付随する res/xml/method.xml はとりあえず以下のように。

```xml
<input-method xmlns:android="http://schemas.android.com/apk/res/android">
    <!--
    android:imeSubtypeMode="keyboard"となっていればOK
    上2つは表示用
    -->
    <subtype
        android:label="@string/app_name"
        android:imeSubtypeLocale="en_US"
        android:imeSubtypeMode="keyboard"/>
</input-method>
```



### 動作確認

あとはメニューバーの「Run」→「Edit Configurations ...」からLaunch Optionsの`Default Activity`を`Nothing`にすれば準備完了。デバッグしてみれば期待通りの動作をするはずです。

### 嬉しい副作用

さて、KeyboardViewを自作することで以前できなかったことがいとも簡単にできるようになりました。
たとえばユーザー設定に基づいて色設定をコード上で行いたい時。

以下、上記`CustomIME.onCreateInputView`より抜粋。

```kotlin
return layoutInflater.inflate(R.layout.keyboard, null).also { view ->
    keymap.keys.map { id ->
        view.findViewById<Button>(id).also { it.setOnTouchListener(this) }
    }
}
```

この部分をこうしてあげればOK。

```kotlin
return layoutInflater.inflate(R.layout.keyboard, null).also { view ->
    keymap.keys.map { id ->
        view.findViewById<Button>(id).also {
            it.setOnTouchListener(this)

            // foreground
            it.setTextColor(Color.WHITE)

            // background
            val onPress   = Color.GRAY
            val onRelease = Color.BLACK 
            it.background = StateListDrawable().apply {
                // on Press
                addState(
                    intArrayOf(+android.R.attr.state_pressed),
                    ColorDrawable(onPress)
                )
                // on Release
                addState(
                    intArrayOf(-android.R.attr.state_pressed),
                    ColorDrawable(onRelease)
                )
            }
        }
    }
}
```

数年間にできなかったことがこんなにも簡単にできてしまうとは。勉強不足だったなぁ……。



## 所感

やってみての感想としては、「案外簡単にできるんだな」という印象。
多分Androidについて基礎からきちんと勉強すればなんてことはないものでしょう。基礎学習って大事。