キーボードが表示されたときに UITextField を上にスクロールさせる方法

# はじめに

よくある入力フォームで、テキストフィールドをタップしたときには、キーボードに隠れないように、自動的にテキストフィールドが上にスクロールしてほしいですよね。
コーディングしなくても勝手にキーボードを回避してくれる機能はSwiftにはありませんので、自分で実装する必要があります。
世の中いろいろサンプルが出回っていますが、自分なりにようやく書けるようになったので、ここにまとめます。

# 当記事のコードの特徴

- テキストフィールドにタッチすると、
    - キーボードに隠れない場合は、何も起きません。
    - キーボードに隠れる場合は、テキストフィールドがキーボードの上辺に合わせてスクロールします。
- ユーザーが入力を完了すると、キーボードが下がるのに合わせて、テキストフィールドが元の位置にスクロールします。
- 対象のテキストフィールドがアクティブなときに、キーボードツールバーに上/下ボタンや完了ボタンを設定します。

# 実装イメージ


![keyboardupdownsample.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/292215/4b8ee7d6-c682-c5ad-d7fa-8d3be5432f9f.gif)

# コードの説明

ここで説明するコードは、以下のリポジトリで公開しています。
https://github.com/mnaruse/KeyboardUpDownSample

## Storyboard での注意点

UITextFieldを、UIScrollViewの中に設置しておく必要があります。

## IBOutlet での注意点

大抵の場合、複数のUITextFieldが置いてあると思いますので、[UITextField] というUITextFieldsの配列を作っておきます。

```swift
@IBOutlet private var textFields: [UITextField]!
```

ViewControllerのファイルと、Storyboardのファイルを並べて開き、このコード上のIBOutletから、Storyboard内の対象のUITextFieldに向かって、接続していきます。
最終的にこんな感じになります。

![iboutlet_textfields.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/292215/11ed1fd4-de5b-710c-a6de-49fb381194bf.png)

## キーボードのサイズが変化すると実行されるイベントハンドラー のコードのポイント

キーボードのサイズが変化すると実行される関数について、ポイントを説明します。

```swift
/// キーボードのサイズが変化すると実行されるイベントハンドラー
/// テキストフィールドが隠れたならスクロールする。
///
/// キーボードの退場でも同じイベントが発生するので、編集中のテキストフィールドがnilの時は処理を中断する。
@objc private func keyboardChangeFrame(_ notification: Notification) {
    // 編集中のテキストフィールドがnilの時は処理を中断する。
    guard let textField = editingTextField else {
        return
    }

    // キーボードのframeを調べる。
    let userInfo = notification.userInfo
    let keyboardFrame = (userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as!
NSValue).cgRectValue
```

以下のように、編集中のUITextFieldのframeを、キーボードのframeと同じ座標系にするところがポイントです。
これによって、重なり具合の比較が容易になります。

```swift
    // テキストフィールドのframeをキーボードと同じウィンドウの座標系にする。
    guard let textFieldFrame = view.window?.convert(textField.frame, from: textField.superview)
else {
        return
    }
```

次に、以下のように、編集中のテキストフィールド足す余白分(以下の例では8ptに設定しています)が、キーボードと重なっていないか調べて、それをスクロールするかしないかの判断基準にするところもポイントです。

- 重なっていなかったら、何もせず処理を終了します。
- 重なっていたら、テキストフィールドをキーボードの上辺に合わせて上にスクロールします。

```swift
    /// テキストフィールドとキーボードの間の余白(自由に変更してください。)
    let spaceBetweenTextFieldAndKeyboard: CGFloat = 8
    // 編集中のテキストフィールドがキーボードと重なっていないか調べる。
    // 重なり = (テキストフィールドの下端 + 余白) - キーボードの上端
    var overlap = (textFieldFrame.maxY + spaceBetweenTextFieldAndKeyboard) - keyboardFrame.minY
    if overlap > 0 {
        // 重なっている場合、キーボードが隠れている分だけスクロールする。
        overlap = overlap + scrollView.contentOffset.y
        scrollView.setContentOffset(CGPoint(x: 0, y: overlap), animated: true)
    }
}
```

# コード全体

コードの全体は以下の通りです。

なお、コード内で実装するかしないか任意な点は以下の通りです。

- 対象のテキストフィールドがアクティブなとき、キーボードのツールバーに、上下ボタンや完了ボタンを設定するかどうかは任意です。
    - この部分→ `addPreviousNextableDoneButtonOnKeyboard(textFields: textFields, previousNextable: true)`
    - UIViewControllerのExtensionを作っていますので、よかったら、以下の記事でご覧ください。
    - [UIToolBarに上下ボタンや完了ボタンをつけるExtension](https://qiita.com/m_rn/items/d24056544c9f29875d91)
- ビューがタップされた時に、キーボードを下げるかどうかは任意です。

コード全体

```swift
//
//  ViewController.swift
//  KeyboardUpDownSample
//
//  Created by Miharu Naruse on 2020/11/15.
//

import UIKit

class ViewController: UIViewController {
    @IBOutlet private var scrollView: UIScrollView!
    @IBOutlet private var textFields: [UITextField]!

    /// 編集中のテキストフィールド
    private var editingTextField: UITextField?

    /// キーボードが登場する前のスクロール量
    private var lastOffsetY: CGFloat = 0.0

    override func viewDidLoad() {
        super.viewDidLoad()

        // 全てのテキストフィールドのデリゲートになる。
        for textField in textFields {
            textField.delegate = self
        }
        // テキストフィールドとキーボード関連の処理について、通知センターの設定をする。
        setNotificationCenter()

        // 任意: 対象のテキストフィールドがアクティブなとき、キーボードのツールバーに、前後ボタンや完了ボタンを設定する。
        addPreviousNextableDoneButtonOnKeyboard(textFields: textFields, previousNextable: true)
    }

    /// 任意: ビューがタップされた時に実行される処理
    @IBAction func tapView(_ sender: UITapGestureRecognizer) {
        // キーボードを下げる。
        view.endEditing(true)
    }
}

// MARK: - Extensions テキストフィールドとキーボード関連の処理

extension ViewController {
    /// テキストフィールドとキーボード関連の処理について、通知センターの設定をする。
    private func setNotificationCenter() {
        /// デフォルトの通知センターを取得
        let notification = NotificationCenter.default

        // キーボードのframeが変化した時のイベントハンドラーを登録する。
        notification.addObserver(self, selector: #selector(keyboardChangeFrame(_:)), name: UIResponder.keyboardDidChangeFrameNotification, object: nil)

        // キーボードが登場する時のイベントハンドラーを登録する。
        notification.addObserver(self, selector: #selector(keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil)

        // キーボードが退場する時のイベントハンドラーを登録する。
        notification.addObserver(self, selector: #selector(keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil)
    }

    /// キーボードのサイズが変化すると実行されるイベントハンドラー
    /// テキストフィールドが隠れたならスクロールする。
    ///
    /// キーボードの退場でも同じイベントが発生するので、編集中のテキストフィールドがnilの時は処理を中断する。
    @objc private func keyboardChangeFrame(_ notification: Notification) {
        // 編集中のテキストフィールドがnilの時は処理を中断する。
        guard let textField = editingTextField else {
            return
        }

        // キーボードのframeを調べる。
        let userInfo = notification.userInfo
        let keyboardFrame = (userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as! NSValue).cgRectValue

        // テキストフィールドのframeをキーボードと同じウィンドウの座標系にする。
        guard let textFieldFrame = view.window?.convert(textField.frame, from: textField.superview) else {
            return
        }

        /// テキストフィールドとキーボードの間の余白(自由に変更してください。)
        let spaceBetweenTextFieldAndKeyboard: CGFloat = 8

        // 編集中のテキストフィールドがキーボードと重なっていないか調べる。
        // 重なり = (テキストフィールドの下端 + 余白) - キーボードの上端
        var overlap = (textFieldFrame.maxY + spaceBetweenTextFieldAndKeyboard) - keyboardFrame.minY
        if overlap > 0 {
            // 重なっている場合、キーボードが隠れている分だけスクロールする。
            overlap = overlap + scrollView.contentOffset.y
            scrollView.setContentOffset(CGPoint(x: 0, y: overlap), animated: true)
        }
    }

    /// キーボードが登場する通知を受けると実行されるイベントハンドラー
    @objc private func keyboardWillShow(_ notification: Notification) {
        // キーボードが登場する前のスクロール量を保存しておく。
        lastOffsetY = scrollView.contentOffset.y
    }

    /// キーボードが退場する通知を受けると実行されるイベントハンドラー
    @objc private func keyboardWillHide(_ notification: Notification) {
        // スクロール量をキーボードが登場する前の位置に戻す。
        scrollView.setContentOffset(CGPoint(x: 0, y: lastOffsetY), animated: true)
    }
}

// MARK: - Extensions UITextFieldDelegate テキストフィールドとキーボード関連の処理

extension ViewController: UITextFieldDelegate {
    func textFieldDidBeginEditing(_ textField: UITextField) {
        // テキストフィールドの編集が開始された時に実行される処理。
        // どのテキストフィールドが編集中か保存しておく。
        editingTextField = textField
    }

    func textFieldDidEndEditing(_ textField: UITextField) {
        // テキストフィールドの編集が終了した時に実行される処理。
        // 編集中のテキストフィールドをnilにする。
        editingTextField = nil
    }

    func textFieldShouldReturn(_ textField: UITextField) -> Bool {
        // 改行キーが入力された時に実行される処理。
        // キーボードを下げる。
        view.endEditing(true)
        // 改行コードは入力しない。
        return false
    }
}
```

# おわりに

読んでいただきありがとうございました。
何か少しでもお役に立てたら嬉しいです:pray:
「こういうやり方もあるよ」とか「ここはこうした方がいいよ」とか、コメントやアドバイス、あるいは質問があれば、よかったらコメントお願いします。


# 関連記事

- GitHub
    - https://github.com/mnaruse/KeyboardUpDownSample
- [【Qiita】UIToolBarに上下ボタンや完了ボタンをつけるExtension](https://qiita.com/m_rn/items/d24056544c9f29875d91)