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

# 導入

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

- [iOS カスタムキーボードの基本](#ios-カスタムキーボードの基本)
- [カスタムキーボードのプロジェクト立ち上げ](#カスタムキーボードのプロジェクト立ち上げ)
- [キーのレイアウト方法](#キーのレイアウト方法)
- [キーを押して文字を挿入する方法](#キーを押して文字を挿入する方法)
- [キーを押して文字を削除する方法](#キーを押して文字を削除する方法)
- [カーソルの位置を動かす方法](#カーソルの位置を動かす方法)
- [キーを押した時に音を鳴らす方法](#キーを押した時に音を鳴らす方法)
- [画面の向きに応じてレイアウトを変更する方法](#画面の向きに応じてレイアウトを変更する方法)
- [フルアクセスのオンオフをチェックする方法](#フルアクセスのオンオフをチェックする方法)
- [設定アプリを作る方法](#設定アプリを作る方法)
- [収容アプリ名とキーボード名の付け方](#収容アプリ名とキーボード名の付け方)
- [ノッチあり iPhone に対応させる方法](#ノッチあり-iphone-に対応させる方法)
- [スペルミスチェックや補完をする方法](#スペルミスチェックや補完をする方法)
- [日本語入力をする方法](#日本語入力をする方法)
- [左右端のキーの反応速度を正常にする方法](#左右端のキーの反応速度を正常にする方法)
- [下端をスワイプしてもコントロールセンターを出現させない方法](#下端をスワイプしてもコントロールセンターを出現させない方法)
- [実装不可能だと思われること](#実装不可能だと思われること)

※ 本記事は Xcode を IDE として話を進めていきます。Xamarin でやりたいという方は[こちらの記事](https://qiita.com/Kyomesuke3/items/ff963c687d691f3dd53d)を参考にしてください。また、Android のカスタムキーボードを作りたいよという方は[こちらの記事](https://qiita.com/Kyomesuke3/items/e654363bad7f69e6b0fd)をどうぞ。

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

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

## リリース要件

- ~~キーボードとして０〜９の数字を入力できること~~([ソース](https://medium.com/swiftist/read-this-before-submitting-a-custom-keyboard-on-ios-b351e5d028d2))
- 次のキーボードに切り替えるためのボタンが搭載されていること
- 収容アプリによって設定を切り替えることができること
- ゲームなど余計な機能は搭載されていないこと
- サポートサイトおよびプライバシーポリシーサイトが用意されていること

などがあります（[Review Guilelinesも参考に](https://developer.apple.com/app-store/review/guidelines/#extensions)）。ちなみに Keybaord Extension は最大で 30MB 以内の処理負荷に収まる必要があるらしいです（[ソース](https://stackoverflow.com/questions/44275990/memory-limit-for-keyboard-extension)）。

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

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

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

1. この際、スキームのアクティベートをしろと言ってくるので Activate を押します。<br />![project6.png](https://qiita-image-store.s3.amazonaws.com/0/67153/45073dff-4528-80ce-c4f4-db7822bbabaf.png)

1. 構成が以下のようになれば OK です。<br />![構成.png](https://qiita-image-store.s3.amazonaws.com/0/67153/7479c33d-0581-6b56-178a-84cc140c1401.png)

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

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

# キーのレイアウト方法

## キーの追加

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

## レイアウトの仕方

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

```swift
button.translatesAutoresizingMaskIntoConstraints = false
```

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

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

デフォルトの例では、

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

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

```swift
// １つだけ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`にてメソッドを指定します。

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

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

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

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

## 単純に１文字消す場合

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

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

```swift
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`はボタンを押してから連続削除を開始するまでの時間です。

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

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

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

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

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

`AudioToolbox`の`AudioServicesPlaySystemSound()`を使いましょう。

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

鳴らせる音に関しては[ここ](http://iphonedevwiki.net/index.php/AudioServices)を参照してください。ここにない番号の音声もあるので、頑張って調べてみてください。

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

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

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

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

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

- [Swift4 OpenAL で caf 音源を鳴らす方法](https://qiita.com/Kyomesuke3/items/27b0ac38eceb16762dab)
- [Swift4 綺麗なサイン波音をその場で生成して鳴らす方法](https://qiita.com/Kyomesuke3/items/92bed9c176ee54615069)

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

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

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

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

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

```swift
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`で判別できます。

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

```swift
// 縦向きのときの制約
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()`より後です。

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

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

# 設定アプリを作る方法

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

```swift
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のみが表示名になり、内包しない場合は、２つのDisplay nameをハイフンで繋いだ表示名になります。

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

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

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

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

- [UITextChecker でスペルチェック](https://sites.google.com/a/gclue.jp/swift-docs/ni-yinki100-ios/uikit/uitextcheckerdesuperuchekku)
- [UILexicon を使ってカスタムキーボードに用語集を表示する](https://dev.classmethod.jp/references/ios8-uilexicon/)

# 日本語入力をする方法

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

→ iOS 向けかな漢字変換ライブラリ:[Kaede Project](https://github.com/Kyome22/Kaede)

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

参考：[Swift で Mozc for iOS を実装したいのですが、C++（ヘッダーファイル）の読み込みで躓いています。](https://ja.stackoverflow.com/questions/23865/swift%E3%81%A7mozc-for-ios%E3%82%92%E5%AE%9F%E8%A3%85%E3%81%97%E3%81%9F%E3%81%84%E3%81%AE%E3%81%A7%E3%81%99%E3%81%8C-c-%E3%83%98%E3%83%83%E3%83%80%E3%83%BC%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB-%E3%81%AE%E8%AA%AD%E3%81%BF%E8%BE%BC%E3%81%BF%E3%81%A7%E8%BA%93%E3%81%84%E3%81%A6%E3%81%84%E3%81%BE%E3%81%99)

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

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

```swift
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
}
```

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

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

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

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

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

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

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

参考:[UITextDocumentProxy の setMarkedText を(まだ)使ってはいけない。](https://qiita.com/En3_HCl/items/1f1a327ce3050aad47b5)

# 参考文献

- [iOS Keyboard Extensionでキーボードを介さない操作を検知する](https://qiita.com/En3_HCl/items/6111815ff5844cc59b38)