【iOS】画面をタップしたらキーボードを閉じる処理を共通化してみる

## はじめに

UIView の `endEditing(_:)` は特定のテキストフィールドによらずにキーボードを閉じる方法として有効です。

次のような実装で、画面のタップでキーボードを閉じることができます。

```swift
import UIKit

final class ViewController: UIViewController {

    @IBOutlet private weak var textField: UITextField!

    override func viewDidLoad() {
        super.viewDidLoad()
        view.addGestureRecognizer(UITapGestureRecognizer(
            target: self,
            action: #selector(endEditing(sender:))))
    }

    @objc func endEditing(sender: Any) {
        view.endEditing(true)
    }
}
```

しかし、このコードでは『画面のタップでキーボードを閉じる』要件を満たすには一般的には不完全です。

```swift
view.endEditing(true)
```

たとえば、ナビゲーションコントローラーを使用していて、表示中の画面ではナビゲーションバーを表示しているとします。すると、ナビゲーションバーをタップしてもキーボードを閉じることはできません。

これは、ナビゲーションバーがビューコントローラの管理するビューの階層配下に存在しないことに起因しています。

<img width="1158" alt="Screen_Shot_2020-05-09_at_18_44_46.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63842/78ebf5fe-8aa5-b000-d0df-98420e9d2aaa.png">

ナビゲーションバーにも UITapGestureRecognizer を追加すれば、表示中のキーボードを閉じることはできますが、同じようなコードを何度も記述するのは面倒です。

これをできる限り共通化してみたいと思います。

## `UIGestureRecognizer` をレスポンダチェイン可能に拡張する

UIGestureRecognizer は `target`, `action` の２つのパラメータを指定して初期化します。

```swift
UITapGestureRecognizer(target: self, action: #selector(....))
```

２つのパラメータに `nil` を渡すことが可能なので、一見すると nil-target action が機能するように見えますが、有効な値として機能しません。

これを次のように初期化した場合は、レスポンダチェインを辿って、ファーストレスポンダがセレクタを実行するように拡張してみます。

```swift
// target を指定できないようにして、レスポンダチェインでセレクタを実行させる
UITapGestureRecognizer(#selector(....))
```

次のような実装を行います。

```swift:レスポンダチェインでセレクタを実行させるサンプルコード
import UIKit
import ObjectiveC

public extension UIGestureRecognizer {
    convenience init(_ selector: Selector, cancelsTouchesInView: Bool = false) {
        self.init(Target(selector))
        self.cancelsTouchesInView = cancelsTouchesInView
    }

    private convenience init(_ target: Target) {
        self.init(target: target, action: #selector(Target.action(sender:)))
        objc_setAssociatedObject(self, &Target.associatedKey, target, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
    }

    private final class Target {
        static var associatedKey: Void = ()

        private let selector: Selector

        init(_ selector: Selector) {
            self.selector = selector
        }

        @objc func action(sender: Any) {
            UIApplication.shared.sendAction(selector, to: nil, from: nil, for: nil)
        }
    }
}
```

これを使って共通化します。

## グローバルな共通化

AppDelegate または、SceneDelegate のウィンドウの初期化時に、次のようにして UIGestureRecognizer を追加します。こうすることで、ファーストレスポンダが `resignFirstResponder` を実行します。

```swift:AppDelegateの場合
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    var window: UIWindow? {
        didSet {
            window?.addGestureRecognizer(
                UITapGestureRecognizer(#selector(resignFirstResponder)))
        }
    }
}
```

```swift:SceneDelegateの場合
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow? {
        didSet {
            window?.addGestureRecognizer(
                UITapGestureRecognizer(#selector(resignFirstResponder)))
        }
    }
}
```

たったこれだけの実装で画面をタップするとキーボードが閉じるようになります。

ビューコントローラもナビゲーションバーも、通常はウィンドウの階層配下なので、
個別のビューに対して、UIGestureRecognizer の追加は不要になるわけです。

<img width="300" alt="Untitled.mov.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63842/b7fa2f4d-e7b1-f9b3-6a6f-a7863fa8bfbe.gif">

処理が共通化されてナビゲーションバーなども含めて、どこをタップしてもキーボードが閉じるようになりました 🎉

## 特定のビューだけに制限した共通化

とはいえ、ウィンドウにタップジェスチャーを追加してしまうと、柔軟な対応ができなかったりと面倒な場合もあるので、特定のビューだけに制限してタップジェスチャーを追加したい場合もあると思います。

その場合は次のようなカスタムビューを用意して、インターフェースビルダーやコードでこれら（またはサブクラス）を配置すると良いでしょう。

```swift:CustomView.swft
class CustomView: UIView {
    override init(frame: CGRect) {
        super.init(frame: frame)
        addGestureRecognizer(UITapGestureRecognizer(#selector(resignFirstResponder)))
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        addGestureRecognizer(UITapGestureRecognizer(#selector(resignFirstResponder)))
    }
}
```

```swift:CustomNavigationBar.swift
class CustomNavigationBar: UINavigationBar {
    override init(frame: CGRect) {
        super.init(frame: frame)
        addGestureRecognizer(UITapGestureRecognizer(#selector(resignFirstResponder)))
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        addGestureRecognizer(UITapGestureRecognizer(#selector(resignFirstResponder)))
    }
}
```

これらのビューをタップするとキーボードが閉じます。

ビュー階層とレスポンダチェインには関係性が無いので、ナビゲーションバーに対して上記の UITapGestureRecognizer を追加してもファーストレスポンダが `resignFirstResponder` を処理します。

これで、ビューコントローラからキーボードを閉じるボイラープレートを消すことができます。

## 関連記事

[【iOS】UITextField のキーボードを閉じる処理について](https://qiita.com/takehito-koshimizu/items/d4bff11017c8f387486d)