11
9

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 3 years have passed since last update.

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

Last updated at Posted at 2020-06-14

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

今回は公式ドキュメントにも紹介されているため今までお世話になってきた方も多いであろう、KeyboardおよびKeyboardViewの代替を試みるものになっています。

なにがあったか

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

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

どうするか

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

Viewまわり

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

<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:minWidthandroid:layout_columnWeight。こいつらを指定することで見覚えあるアレに近づきます。

<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。

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をキーとしたハッシュマップを用意しておき、ここから随時取り出す形に。

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が以下の通り。

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 はこのようにしておきます。

<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 はとりあえず以下のように。

<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 ActivityNothingにすれば準備完了。デバッグしてみれば期待通りの動作をするはずです。

嬉しい副作用

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

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

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

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

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について基礎からきちんと勉強すればなんてことはないものでしょう。基礎学習って大事。

11
9
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
11
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?