65
52

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.

初心者がAndroid IMEアプリをつくって思ったこと

Last updated at Posted at 2017-10-01

追記: 2020/06/13
API Level 29からキーボード周りが非推奨になった上にAnkoが開発中止してしまったため、ここの内容は現在ほとんどがムダになりました!作ったアプリも公開停止されてるオマケつき!
最新(?)情報に対応させた記事も書きましたが、この記事は残しておく予定です。

1. 簡単なKeyboardViewをいちから作る


タイトル通り、Androidプログラミング初心者がキーボードアプリを作ってみたときの話です。
実際にやってみて、意外とAndroidのIME/仮想キーボード実装に関する日本語情報が少ないことに気がついたので、ついでにまとめてみたいと思います。
(Android開発者にとっては当たり前のことを書いてるかもしれませんが……)

経緯とか

きっかけは自分がLAPP環境を弄るプログラマになって半年のこと。空き時間に勉強するためスマホにターミナルアプリ(Termux)をダウンロードしPHPとVimを入れてみました。
ここで問題になるのはキーボード。Google日本語入力にはESCキーもCtrlキーもありませんので、ハッカー用キーボードアプリも一緒に落としてみました。

しかし……なんか、ダサい!
当時すでにフラットデザインも浸透していたにも関わらずメジャーなものは前時代的な立体感のあるキーで実装するアプリがほとんど(最近はBehecodeboardのように見た目が優れているものも出てきました)。

おまけに……入力が面倒くさい!!
スマホなのでキーのひとつひとつが小さいのでプレビュー機能が必須。しかもPHPでは必須文字の$がロングタップや2回タップしないと入力できないものがほとんど。
キー入力の遅さがどれだけ命取りになるか、Qiitaの記事を読むような皆様方ならきっとわかってくれることでしょう。

極めつけに……デカイ!!!!
ただでさえスマホという小画面なのにQWERTYキーボードは基本5段なので高い。日本語入力では高さ最低+左右寄せで更に低くしているので尚更。
結果見れるコード量も減ってなんかイライラします。

そのあとも色々探してみましたが、なかなかいいアプリは見つからず。
そうやって云々唸っている間にKotlinがAndroidの正式言語になることが発表されたので「これはいい機会だ」と思い、Kotlinで自作することにしました。

目指したもの

  • 操作性を失わない程度に省スペース
    → 電話配列ベースにUS配列キーボードのすべてを詰め込む
  • フリックによる省アクション数
    → フリックの距離も検出して大文字小文字を1キー1方向にまとめる。文字はすべて1アクションで入力できるように。
  • ターミナルやナビゲーションバーにベストマッチするUI
    → Google日本語入力の見た目を継続的にパクる(その上マテリアルデザインとターミナルはデザインの親和性が高い)

以上の要件を満たす、ターミナル操作/コード編集用のスマホ向けキーボードです。

初心者の自分がつまづいたところ

公式のCreating an Input methodを流し読んで、とりあえずKotlinで写経が終わった時点での話です。Java表記ではないのであしからず。

Androidやキーボードに関係ないところでも結構悩みましたが、ここでは割愛します。
またGoogle日本語入力やATOKなどにあるような予測変換系の情報はありません。もし参考にしたい人がいたらごめんなさい。

2018/01/09 追記
上記の公式チュートリアルに加えて、以下の記事も読んでおくとスムーズに理解できると思います。
AndroidカスタムIMEの作り方DX

特にxmlに関する内容は現在公式には一切ありませんので確認した方がいいでしょう。
このようなAndroidIMEに関する丁寧な記事が増えてほしいと切に願っています。

フリックってどうやって検知するんですか

KeyboardView.setOnTouchListenerを利用します。
MotionEvemtのアクションでフリック検知を、x座標とy座標で方向や距離の算出を行います。

IME.kt
class IME : InputMethodService(),
            KeyboardView.OnKeyboardActionListener {
    var tapX = 0
    var tapY = 0
    lateinit var keyboardView: KeyboardView
    override onCreateInputView(): View {
        // 事前にkeyboardViewを初期化
        keyboardView.setOnTouchListener Listener@ { _, motionEvent ->             
            val x = motionEvent.x
            val y = motionEvent.y
            when (motionEvent.action and MotionEvent.ACTION_MASK) {
                MotionEvent.ACTION_DOWN -> {
                    Log.d("LocalLog", "タッチしました")
                    tapX = x
                    tapY = y
                    return@Listener false
                }
                MotionEvent.ACTION_MOVE -> {
                    Log.d("LocalLog", "フリック(右へ${tapX - x}px、下へ${tapY - y}px)")
                    return@Listener true // 重要:あとで解説
                }
                MotionEvent.ACTION_UP -> {
                    Log.d("LocalLog", "タッチ終わりました")
                    return@Listener false
                }
                else -> return@Listener true // ここも重要
            }
        }
        return keyboardView
    }
}

文字入力については、別途変換用のクラスを用意しておき、res/xml/keyboard.xmlのandroid:codesとフリック情報から文字コードを導き出せばいいと思います。
(Mozcなんかは独自タグとXmlParserを使ってキー情報と入力できる文字をまとめていたけど、実装がかなりキツそうだったのでやめました)

ちなみにどうでもいい上に当たり前なことですが、**KeyboardView外でのタッチ情報は捕捉できません。**キーボードの横幅が画面いっぱいなら一応x座標は取れるのですがy座標は0のままで、これはフリック距離も検出したいとき大きな痛手となってしまいます。
おかげさまで今回つくってみたアプリのキー配置は電話配列をもとにしているにも関わらず、0が最上段に来てしまう羽目になりました。他の日本語IMEの英数入力と併用してると誤タップの嵐でとても使いにくいです。ちくしょう……

フリックしたとき別のキーが押下されてしまうんですけど

上記リスナーでは、falseを返すことで以降のキー表示に関するイベントが発火されるようになっています。
とりあえずはACTION_DOWNとACTION_UPのとき以外にtrueを返すようにしてあげれば見た目の問題は解消します。

またonKeyonReleaseが移動先の値(primaryCode)を返す点については、onPressで得られる値を可変プロパティとして保持・利用することで解決できます。

修飾キーの入力をしたいんですけど

ポイントは3つです。

  1. 修飾キー入力は非修飾キー入力と一緒に行う
  2. すべてInputConnection.sendKeyEvent系関数を使う(sendKeyCharやcommitTextを使わない)
  3. 修飾キーのメタ情報を正確に渡す

3のメタ情報についてはひとつの修飾キーにつきKeyEvent.META_XXX_ONKeyEvent.META_XXX_LEFT_ON(RIGHT版でもOK)との論理和で、複数個押している場合はそれらの論理和を渡します。動作確認についてはTermuxにて左Ctrl-左Alt-vの入力でクリップボードからの貼付けができるか、などを試してみてください。

また1についてはイベント送信の順序も重要です。
こんな感じで ModDown → MainDown → MainUp → ModUp の順で行う必要があります。

IME.kt
class IME : InputMethodService(),
            KeyboardView.OnKeyboardActionListener {

    private fun getModKeyMetaInfo(): Int {
        // イベント送信後に押された状態になるすべての修飾キーの情報の論理和を返す
        // 例: 左Ctrlキー押下中+右Shiftを押下 --> 
        // return KeyEvent.META_CTRL_ON or KeyEvent.META_CTRL_LEFT_ON or
        //            KeyEvent.META_SHIFT_ON or KeyEvent.META_SHIFT_RIGHT_ON
    }

    override fun onKey(/*args*/) {
        // Ctrl-Aを入力したい(Ctrl-Shift-aと同義)
        val ic    = currentInputConnection ?: return
        val now   = System.currentTimeMillis()
        val down  = KeyEvent.ACTION_DOWN
        val up    = KeyEvent.ACTION_UP
        val ctrl  = KeyEvent.KEYCODE_CTRL_LEFT
        val shift = KeyEvent.KEYCODE_SHIFT_RIGHT
        val a     = KeyEvent.KEYCODE_A
        ic.apply {
            sendKeyEvent(KeyEvent(now, now, down, ctrl,  0, getModKeyMetaInfo()))
            sendKeyEvent(KeyEvent(now, now, down, shift, 0, getModKeyMetaInfo()))
            sendKeyEvent(KeyEvent(now, now, down, a,     0, getModKeyMetaInfo()))
            sendKeyEvent(KeyEvent(now, now, up,   a,     0, getModKeyMetaInfo()))
            sendKeyEvent(KeyEvent(now, now, up,   ctrl,  0, getModKeyMetaInfo()))
            sendKeyEvent(KeyEvent(now, now, up,   shift, 0, getModKeyMetaInfo()))
        }
    }
}    

Char型の小文字xをKeyEvent.KEYCODE_xにしたいんですけど

コードを読みやすくするため、今回制作したアプリではキーマップ定義ではできるだけChar型で定義していました。
しかしこれだと修飾キー適用に対応できないので、以下のようにして変換する必要があります。

なお、KeyEvent内で定義されていない文字(!とか?とか)は変換こそはできますが期待通りの入力はできないでしょう(どうなるかは未確認)。その他入力に際して面倒な条件分岐も必要なためこの手法を取ることはおすすめしませんが、使用する場合はどの文字がKeyEventに変換できるかを公式のドキュメントで確認しておくとよいでしょう(KeyEvent_KEYCODE_XXXが用意されてるものはKeyEvent化=修飾が可能)。

CharConverter.kt
class CharConverter {
    private var charArray = CharArray(1)
    private val keyCharacterMap = 
            KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD)

    fun convert(char: Char): Int {
        charArray[0] = char
        return keyCharacterMap.getEvents(charArray)[0].keyCode
    }
}

fun testCharConverter(arg: Char) {
    val converter = CharConverter()
    Log.d("LocalLog", "KeyEventコードの確認: ${arg}→${converter.convert(arg)}")
}

キーに表示される文字やアイコンをコード上で変えたいんですけど

まずkeyboard.getKeysで得られる配列から目的のキーを取得。
つづいて、文字を変えたい場合はlabelに文字列を、アイコンを変えたい場合はkey.label = nullとしてからkey.iconに適用するDrawableを渡します。
最後に再描画のためにkeyboardView.invalidateKeyあるいはkeyboardView.invalidateAllKeysメソッドを呼び出します。

invalidateKeyには引数が必要ですが、配列から対象となるキーを取り出すときに用いたインデックス番号を渡します。
あと、アイコン設定におけるlabelの事前null化はなぜか必須です。

CustomKeyboardView.kt
class CustomKeyboardView(/*args*/) : KeyboardView(/*args*/) {
    fun changeKeyFace(index: Int, text: String) {
        changeKeyFace(index, { key ->
            key.label = text
        })
    }

    fun changeKeyFace(index: Int, drawable: Drawable) {
        changeKeyFace(index, { key ->
            key.label = null
            key.icon = drawable
        })
    }

    private fun changeKeyFace(index: Int, let: (Keyboard.Key) -> Unit) {
        keyboard?.keys[index]?.let(let) ?: return
        invalidateKey(index)
    }
}

キーの背景色や形状を部分的に変えたいんですけど

すべてのキー背景を変える場合はres/layout/keyboardview.xmlのandroid:keyBackgroundにdrawableを指定すればOKです。

対してキー毎に適用させたい場合は、以下のようにKeyboardView.onDrawメソッド内でDrawableを生成し、引数のcanvasを用いてdrawしてあげないといけません。

CustomKeyboardView.kt
class CustomKeyboardView(/*args*/) : KeyboardView(/*args*/) {
    private lateinit var key: Keyboard.Key

    override fun setKeyboard(keyboard: Keyboard) {
        super.setKeyboard(keyboard)
        key = keyboard.keys[0]
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        // 一番最初のキーを常に白く塗りつぶす
        val x = key.x + x.toInt()
        val y = key.y + y.toInt()
        val w = key.width
        val h = key.height
        val color = Color.WHITE
        val sd = ShapeDrawable()
        sd.apply {
            setBounds(x, y, x + w, y + h)
            paint.color(color)
            draw(canvas)
        }
}

これを応用することで、キーボードに罫線(仕切り線?)を引くことも可能です。

キーボードを左右寄せにしたいんですけど

左右それぞれに寄せた2つのres/xml/keyboard.xmlを用意した上で、KeyboardView.keyboardに入れるKeyboardを動的に指定するだけです。
ただし変更を適用した直後でも反映できるようにInputMethodService.setInputViewも使う必要があります。

IME.kt
class IME : InputMethodService(),
            KeyboardView.setOnActionListener {
    var isLayoutForLeft = true
    lateinit var keyboardView: KeyboardView

    override fun onCreateInputView(): View {
        keyboardView = /*省略*/

        val keyboardId = if (isLayoutForLeft) {
            R.xml.keyboard_for_left
        } else {
            R.xml.keyboard_for_right
        }
        keyboardView.keyboard = Keyboard(context, keyboardId)
        keyboardView.setOnKeyboardActionListener(this)

        return keyboardView
    }

    override fun onStartInput(/*args*/) {
        super.onStartInput(/*args*/)
        // キーボード再表示時はonCreateViewが呼び出されない
        // 対してonStartInputは表示の際に必ず呼ばれる
        setInputView(onCreateView())
    }

    override fun onKey(primalyCode: Int, keyCodes: IntArray?) {
        // primaryCodeが128なら左右寄せを切り替える
        if (primalyCode == 128) {
            isLayoutForLeft = !isLayoutForLeft
            setInputView(onCreateView())
        }
    }
}

ちなみに以前私のアプリではres/xml/keyboard.xmlにてkeyboard.keyWidth="100%"を定義しておき、keyboardView.keyboardへ渡す前に各キー幅を直接変更することで対応させていましたが、高さを変えられなかったり補正処理が必要がだったりといろいろ面倒だったので、左右配置を変えるだけであればオススメしません。

プレビューの内容を動的に変えたいんですけど

ほとんどのキーボードは入力できる文字を押下キー上部に出現するポップアップで確認でき、これの実現自体はさほど難しくありません。
またスタイル変更もres/layout/keyboardview.xmlのandroid:keyPreviewLayoutにlayoutを指定してあげればいいので、かなり楽な部類です。
しかしプレビューする文字を含めそれらを動的に変えるとなると話は別でそのための手段は一切用意されていません。

そこで動的なプレビューを作りたい場合はシステムを自作する必要があります。
今回はPopupWindowとAnkoを用いてテキストベースで作ってみました。

PopupPreview.kt
class PopupPreview {
    // ポップアップの実体
    private val popup = PopupWindow().apply {
        inputMethodMode = PopupWindow.INPUT_METHOD_NOT_NEEDED
        isClippingEnabled = true
        isFocusable = false
        isOutsideTouchable = false
    }

    // Ankoを用いたView実装
    private val ui = object : AnkoComponent<Context> {
        lateinit var textView: TextView
            private set

        override fun createView(ui: AnkoContext<Context>): View = with(ui) {
            frameLayout {
                textView = textView {
                    textColor = Color.BLACK
                    textSize = 18f
                    gravity = Gravity.CENTER
                }.lparams {
                    backgroundColor = Color.WHITE
                    popupSize = dip(96)
                    width = wrapContent
                    height = popupSize
                }
            }
        }
    }

    private var standBy = false

    // ポップアップ表示に使用
    var anchorView: View? = null
    var anchorKey: Keyboard.Key? = null
        set(value) {
            field = value
            if (value is Keyboard.Key) {
                keyX = value.x
                keyY = value.y
                keyWidth = value.width
                keyHeight = value.height
            }
        }
    private var popupSize = 0
    private var keyboardX = 0
    private var keyboardY = 0
    private var keyX = 0
    private var keyY = 0
    private var keyWidth  = 0
    private var keyHeight = 0

    /**
     * [popup]の初期化と表示のための準備
     */
    fun setup(view: KeyboardView) {
        val location = IntArray(2)
        view.getLocationInWindow(location)
        anchorView = view
        keyboardX = location[0]
        keyboardY = location[1]
        popup.apply {
            contentView = ui.createView(view.context.UI {}).also {
                val spec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
                it.measure(spec, spec)
            }
            // PopupWindow表示のためにwidthおよびheightの事前入力は必須
            width  = contentView.measuredWidth
            height = contentView.measuredHeight
        }
        standBy = true
    }

    /**
     * @param text 表示させたい文字
     */
    fun show(text: String) {
        if (!standBy) {
            return
        }
        // 押下キー上部のいいところに表示させる
        val pointX = keyboardX + (keyX + keyWidth  / 2) - ui.textView.measuredWidth / 2
        val pointY = keyboardY + (keyY + keyHeight / 2) - (1.25 * popupSize).toInt()

        ui.textView.text = text

        popup.showAtLocation(anchorView, Gravity.NO_GRAVITY, pointX, pointY)
    }

    fun dismiss() {
        if (popup.isShowing) popup.dismiss()
    }
}

これを以下のように操作してあげればとりあえず動的表示できるはず。

IME.kt
class IME : InputMethodService(),
            KeyboardView.OnKeyboardActionListener {
    lateinit var keyboardView: KeyboardView
    val preview = PopupPreview()
    override onCreateInputView(): View {
        keyboardView = /*省略*/
        keyboardView.setOnTouchListener Listener@ { _, motionEvent ->             
            when (motionEvent.action and MotionEvent.ACTION_MASK) {
                MotionEvent.ACTION_DOWN -> {
                    val tapX = motionEvent.x.toInt()
                    val tapY = motionEvent.y.toInt()
                    val key = keyboard.keys.find { it.isInside(tapX, tapY) }
                    preview.also {
                        it.anchorKey = key
                        it.show("Tap!!")
                    }
                    return@Listener false
                }
                MotionEvent.ACTION_MOVE -> {
                    preview.show("Moving...")
                    return@Listener true
                }
                MotionEvent.ACTION_UP -> {
                    preview.dismiss()
                    return@Listener false
                }
                else -> return@Listener true
            }
        }
        return keyboardView
    }

    override onStartInputView(/*args*/) {
        super.onStartInputView(/*args*/)
        setInputView(onCreateInputView)
        preview.setup(keyboardView)
    }
}

プレビューがキーボード外で見切れるんですけど

そもそもなんて検索したらいいかわからなかった……この場合での「見切れ」を英語ではclippingと言うみたいです。音声波形のクリッピングと同じ使い方ですね。

この問題は元からあるプレビュー機能を使う場合でも発生するので、この問題に対処したい場合も自作システムが必要になります。
PopupWindowを使うなら以前はpopupWindow.isClippingEnabled = falseとするだけでキーボードからはみ出しつつも見切れずに表示できてたのですが、API28以降ではなぜかうまくいかず……StackOverflowにいくつか解決策が出ていますが変化なしだったりパーミッション取得が必要だったりと微妙な感じ。はみ出し表示にこだわるならもう少し調べなければなりませんね。

ここまでやってみての感想

きつい。
Android初心者としては、仕組みもわからないまま設計と実装をする様はまさに五里霧中。幸い本家がガイドを出していたり、StackOverflowにいくつか有用な情報があったので楽できた部分も多かったですが、やはりMozcやHacker's Keyboardなどのコードとにらめっこしている時間が長かったです(加えてオブジェクト指向に従ったコードを見る機会が仕事ではほぼなく、最初は読み方すらわからなかった。更にMozcはAndroid Studioで動かせないから今でも読むことしかできない)。
おそらくこれこそ本来あるべきプログラマーの姿なのだとは思いますが、いくつもの初めてを同時に散らすのはとてもつらいものです。

けれどもおかげで急速にいろんな方面の知識を吸収できたのも事実です(Gitの使い方であったり設計についてだったりも今回始めてきちんと学びました)。
最初はこれでいいやと思っていたコードも開発が進むに連れて「こうした方が後々いいかも」とか「テストないと困るな」とかの考えを抱けるようになってきたのも大きな収穫です。
総じて、プログラマーとしてはとてもよい一歩を踏み出すいい機会になったと思います。

……出来上がったのは、とても流行りそうにないユニークな操作性を持つアプリではありますが。

うん、きっと誰か使ってくれるはず。そうであってほしい。

終わりに

今回作ったアプリはGooglePlayで公開している他、Bitbucketで中身を公開してます。

フリック入力がベースなので、慣れている方であればまさにスーパーベストマッチになるアプリでしょう。その上Google日本語入力よりも小さいサイズにもできるのでヤベーイこと間違いなしです。

そんな当キーボード、よろしければ使ってみたりコードレビューしたりしてくださると嬉しいです。もちろんアプリとしての評価もお待ちしております!

65
52
1

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
65
52

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?