Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
92
Help us understand the problem. What is going on with this article?
@Kyome

iOSカスタムキーボードの作り方

導入

iOS8 からカスタムキーボードが解禁となりましたが、その開発方法を網羅的にまとめてある場所がありません。本記事では iOS カスタムキーボード開発に関するあらゆる知見をここに集約していきたいと思っています。

※ 本記事は Xcode を IDE として話を進めていきます。Xamarin でやりたいという方はこちらの記事を参考にしてください。また、Android のカスタムキーボードを作りたいよという方はこちらの記事をどうぞ。

iOS カスタムキーボードの基本

iOS のカスタムキーボードは Keyboard Extension という App Extension の1つとその収容アプリによって構成されます。収容アプリをデバイスにダウンロードし、環境設定から当該キーボードを追加することで有効になります。つまり、キーボードだけをパッケージングして App Store で配布するということはできません。収容アプリと Keyboard Extension は別々の target となるのでイメージとしては独立した2つのアプリケーションを用意するといった感じになります。

リリース要件

  • キーボードとして0〜9の数字を入力できること
  • 次のキーボードに切り替えるためのボタンが搭載されていること
  • 収容アプリによって設定を切り替えることができること
  • ゲームなど余計な機能は搭載されていないこと
  • サポートサイトおよびプライバシーポリシーサイトが用意されていること

などがあります。ちなみに Keybaord Extension は最大で 30MB 以内の処理負荷に収まる必要があるらしいです(ソース)。とにかく挙動が軽くないとダメらしいです。

カスタムキーボードのプロジェクト立ち上げ

  1. まず、通常通りに Single View アプリケーションを立ち上げます。こちらが収容アプリケーションになります。

  2. 続いて File -> New -> Target -> iOS -> Custom Keybaord Extension から Keyboard Extension のターゲットを追加します。

  3. この際、スキームのアクティベートをしろと言ってくるので Activate を押します。
    project6.png

  4. 構成が以下のようになれば OK です。
    構成.png

ViewControoler.swift が収容アプリケーションのメイン、KeyboardViewController.swift がカスタムキーボードのメインです。KeyboardViewController.swift には次のキーボードに切り替えるためのボタンの例が記述してあります。

この中で重要なのは、handleInputModeList(from:with:)self.textDocumentProxyの2つです。とりあえず iOS シミュレーターでも実機でも良いので実行してみて、キーボードを環境設定から有効にして出現させることができればひと段落です。

キーのレイアウト方法

キーの追加

デフォルトで記述されているnextKeyboardButtonを見れば基本的なキーの追加方法がわかります。UIButton のインスタンスを生成して、タイトルを設定して、ボタンを押した時のアクションをaddTargetで指定してあげ、self.viewaddSubviewで追加してあげれば追加はできます。ここで、自分で UIButton を継承したカスタムボタンクラスを用意していた場合は、そのインスタンスを生成してaddSubviewすれば OK です。追加先がself.inputViewではなく、self.viewで良いのか?という疑問を抱く人がいるかもしれませんが、この UIInputViewController の View は、inputView として読み込まれるのでこれで良いのです。

レイアウトの仕方

まずは、レイアウトを自由に組むためにボタンの設定を変更します。

button.translatesAutoresizingMaskIntoConstraints = false

これをしないとコンパイルは通りますが、実行時に大量の警告を表示してうざかったり、表示が変になったりします(layout の constraints の競合が大量に発生)。

そして、もう一つ重要なこととして、レイアウトのルールを決めるコードは、レイアウトにまつわる全ての UI パーツをaddSubviewしてから記述するようにしてください。そうでないとレイアウトがきちんと発揮されません。

デフォルトの例では、

self.nextKeyboardButton.leftAnchor.constraint(equalTo: self.view.leftAnchor).isActive = true
self.nextKeyboardButton.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).isActive = true

のようにレイアウトのルール決めを行なっていますが、これ以外にも方法はいくつかあります。

// 1つだけConstraintを追加する場合
self.view.addConstraint(NSLayoutConstraint(item: key1, attribute: .leading, relatedBy: .equal,
                                           toItem: self.view, attribute: .leading,
                                           multiplier: 1.0, constant: 0.0))

// 複数のConstraintを追加する場合
self.view.addConstraints([NSLayoutConstraint(item: key1, attribute: .top, relatedBy: .equal,
                                             toItem: self.view, attribute: .top,
                                             multiplier: 1.0, constant: 0.0),
                          NSLayoutConstraint(item: key1, attribute: .width, relatedBy: .equal,
                                             toItem: self.view, attribute: .width,
                                             multiplier: 1 / 3, constant: 0.0),
                          NSLayoutConstraint(item: key1, attribute: .height, relatedBy: .equal,
                                             toItem: nil, attribute: .notAnAttribute,
                                             multiplier: 1.0, constant: 40.0)])

親となるself.viewに対して constraint を追加する方法です。引数の意味は大体は予想つくと思いますが補足をします。幅や高さなどの場合にはmultiplierを指定して相対的な大きさにすることができます。また、constantに値を入れることで大きさや UI パーツどうしの間隔を値で指定することができます。注意点として、左や上からの距離を指定する場合は正の数、右や下からの距離を指定する場合は負の数を入れなければなりません。

Constraintを定義するのは、viewWillLayoutSubviews()の中で行うことをお勧めします。

キーを押して文字を挿入する方法

キーを押した時の処理はaddTargetにてメソッドを指定します。

button.addTarget(self, action: #selector(pushA), for: .touchUpInside)

@objc func pushA() {
    self.textDocumentProxy.insertText("A")
}

文字の挿入にはtextDocumentProxyの持つinsertTextメソッドを使います。例えば、改行やタブ入力などを行いたいときはエスケープシーケンスを用いれば OK です(例:"\n", "\t")。

キーを押して文字を削除する方法

単純に1文字消す場合

@objc func delete() {
    self.textDocumentProxy.deleteBackward()
}

ボタンを押している間連続で消す方法

var deleteStartTime: Date?
var deleteTimer: Timer?

let deleteBtn = UIButton(type: .system)
//タイトルとか設定...
//ボタンを押した瞬間のイベントと離した瞬間のイベントの登録
deleteBtn.addTarget(self, action: #selector(self.deleteDown), for: .touchDown)
deleteBtn.addTarget(self, action: #selector(self.deleteUp), for: [.touchUpInside, .touchUpOutside])

@objc func deleteDown() {
    self.textDocumentProxy.deleteBackward()
    deleteStartTime = Date()
    deleteTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true, block: { (timer) in
        let span: TimeInterval = timer.fireDate.timeIntervalSince(self.deleteStartTime!)
        if 0.4 < span {
            self.textDocumentProxy.deleteBackward()
        }
    })
}

@objc func deleteUp() {
    deleteTimer?.invalidate()
}

spanはボタンを押してから連続削除を開始するまでの時間です。

カーソルの位置を動かす方法

// 1文字分左へ
self.textDocumentProxy.adjustTextPosition(byCharacterOffset: -1)

// 1文字分右へ
self.textDocumentProxy.adjustTextPosition(byCharacterOffset: 1)

キーを押した時に音を鳴らす方法

キーを押した時に短い音を鳴らす方法

AudioToolboxAudioServicesPlaySystemSound()を使いましょう。

AudioServicesPlaySystemSound(1155) //削除音
AudioServicesPlaySystemSound(1156) //普通のクリック音

鳴らせる音に関してはここを参照してください。ここにない番号の音声もあるので、頑張って調べてみてください。

※ 上記のコード、ただ書くだけでは発動しないことがあります。以下のように UIInputView で音を鳴らす許可を与えてください。

extension UIInputView: UIInputViewAudioFeedback {
    open var enableInputClicksWhenVisible: Bool {
        return true
    }
}

キーを押した時に長い音を鳴らす方法

OpenALを使う方法でもAVAudioEngineをつかう方法でもなんでも良いですが、カスタムキーボード上で音(曲?)を鳴らす場合は、「フルアクセスの許可」を行わない限りキーボードがクラッシュしてしまいます。音を鳴らす場合、ユーザにフルアクセスの許可をしてもらう必要があります。フルアクセスの許可をする場所は、環境設定 -> キーボード -> 該当キーボード名 -> フルアクセスの許可です。

音を鳴らすことに関連する記事を投稿しています。

動画の再生中、Music アプリでの音楽再生中に音を止めずにキーのクリック音などを再生したい場合は以下のようにします。

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    if self.hasFullAccess {
        try? AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default)
    }
}

※ この処理はviewWillAppear()より後で行わないと成功しないようです。

画面の向きに応じてレイアウトを変更する方法

viewWillTransitionを用いて回転を検出しUIScreen.main.bounds.sizeで縦横を判定します。

override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
    Swift.print("transition \(size)")
}

func judgeVH() {
  let size: CGSize = UIScreen.main.bounds.size
  if size.width < size.height {
    Swift.print("縦")
  } else {
    Swift.print("横")
  }
}

※ デバイスの種類によってもレイアウトを変えたい時は、UIDevice.current.userInterfaceIdiomで判別できます。

縦か横かを判定できたら、実際にレイアウトを切り替えられるようにします。予め縦向きと横向き用のレイアウト(制約)を作っておき、そのオンオフを切り替えるという方法が賢いと思います。

// 縦向きのときの制約
let verticalConst: NSLayoutConstraint = button.widthAnchor.constraint(equalToConstant: 100)
// 横向きのときの制約
let horizontalConst: NSLayoutConstraint  = buttonwidthAnchor.constraint(equalToConstant: 200)

let size: CGSize = UIScreen.main.bounds.size
if size.width < size.height {
  verticalConst.isActive = true
  horizontalConst.isActive = false
} else {
  verticalConst.isActive = false
  horizontalConst.isActive = true
}

フルアクセスのオンオフをチェックする方法

self.hasFullAccessを用いて判別できます。判別できるのはviewWillAppear()より後です。

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)

    if self.hasFullAccess {
        Swift.print("フルアクセス許可中")
    }
}

設定アプリを作る方法

収容アプリと Keyboard Extension の Capabilities の中の App Groups を ON にしてください。詳しいことはここを参照してください。App Groups Identifier を手に入れたらほとんど普段の UserDefaults と同様の使い方ができます(Identifier の接頭辞には group.をつける事が推奨されています)。

let defaults = UserDefaults(suiteName: "group.com.sample.customkeyboard")

//set default value
defaults.register(defaults: ["settingBool" : true,
                             "settingInt" : 5,
                             "settingDouble" : 3.14])

//getValue
let value1: Bool = defaults.bool(forKey: "settingBool")
let value2: Int = defaults.integer(forKey: "settingInt")
let value3: Double = defaults.double(forKey: "settingDouble")

//setValue
defaults.set(false, forKey: "settingBool")
defaults.set(3, forKey: "settingInt")
defaults.set(8.45, forKey: "settingDouble")

収容アプリ名とキーボード名の付け方

収容アプリと Keyboard Extension は別 target となるので、それぞれに Bundle identifier が割り振られます。
デフォルトだとそれぞれの Bundle name (PRODUCT_NAME)が収容アプリとキーボードの名前となるのですが、それだと、環境設定で表示されるキーボード名が思い通りにいかないことがあります。

例えば、収容アプリのBundle nameがCustomKeyboardでキーボードのBundle nameがCustomKeyboardExtensionだと、

CustomKeyboardExtension - CustomKeyboard

という風に表示されてしまいます。そこで、Bundle nameとは別にBundle display nameをInfo.plistで定義してあげることで、思い通りの表示名にすることができます。ただ、Display nameを定義しても表示に特殊な法則性が見られたので表にしてまとめておきます。

収容アプリのDisplay name キーボードのDisplay name 実際の表示名
ABCDE ABCDE ABCDE
ABCDEFGHI FGHI FGHI
ABCDEFGHI CDEF CDEF
ABCDE FGHI FGHI FGHI
ABCDE-FGHI FGHI FGHI
ABCDE FGHI FGHIJK FGHIJK - ABCDE FGHI

つまり、収容アプリのDisplay name名に内包する部分文字列名をキーボードのDisplay nameにした場合は、キーボードのDisplay nameのみが表示名になり、内包しない場合は、2つのDisplay nameをハイフンで繋いだ表示名になります。

ノッチあり iPhone に対応させる方法

ノッチありのキーボードは画面サイズが広くなったせいで、地球儀マークのボタンや絵文字ボタンが下の空いているスペースに勝手に表示されるようになりました。しかし、Apple によると、地球儀のボタンが2つ存在していると誤解を生んでよくないので、すでに地球儀のボタンがあるときは自前で地球儀のボタンを表示しないようにしろとのことです。地球儀のボタンを表示する必要があるかないかは、self.needsInputModeSwitchKeyで判断可能です。この値は、viewWillLayoutSubviews()の時点で確定するようなので、ここでaddSubview()するかしないかを決めると良さそうです。

スペルミスチェックや補完をする方法

UITextChecker とか UILexicon を使ってください。ただし、日本語には対応していませんので日本語に対応させる場合は、サードパーティ製の API を使ってください。

日本語入力をする方法

ライブラリを作りました(発展途上中)ので使ってやってください。

→ iOS 向けかな漢字変換ライブラリ:Kaede Project

Google のmozcを使うという方法もありますが,赤ちゃんにコーヒーを飲ませるぐらい苦行なので覚悟してとりかかってください.この案件で Kishikawa 先生を知りました。

参考:Swift で Mozc for iOS を実装したいのですが、C++(ヘッダーファイル)の読み込みで躓いています。

左右端のキーの反応速度を正常にする方法

ボタンを左右端の方にレイアウトすると、時折反応が遅くなることがあります。原理はよくわかりませんが、これで正常に動くようになります。

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    let window = self.view.window!
    let gr0 = window.gestureRecognizers![0] as UIGestureRecognizer
    let gr1 = window.gestureRecognizers![1] as UIGestureRecognizer
    gr0.delaysTouchesBegan = false
    gr1.delaysTouchesBegan = false
}

下端をスワイプしてもコントロールセンターを出現させない方法

override func preferredScreenEdgesDeferringSystemGestures() -> UIRectEdge {
    return .bottom
}

実装不可能だと思われること

キーボードの背景を完全に透明にすること

透明なキーボードを画面全体に表示させようと試みましたが、inputView の背景を透明にしても、Layer を透明にしても、SuperView や SuperSuperView の背景を透明にしようとしても UIWindow を透明にしようとしても実現できませんでした。キーボード背景色のあのグレー色を取り除くことはできなさそうです。

挿入中の未確定文字列の背景を変化させること

self.textDocumentProxy.setMarkedText()を使うとなんとなくそれっぽいことはできますが、挙動に難ありなのでやめた方がいいです。

参考:UITextDocumentProxy の setMarkedText を(まだ)使ってはいけない。

参考文献

92
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Kyome
火星に住む犬です。 趣味でmacOS アプリを開発しています。

Comments

No comments
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account Login
92
Help us understand the problem. What is going on with this article?