[Swift4.1][SnapKit] AutoLayoutでUITextViewがキーボードに被らないようにして高さを可変にした

２年前に下書き状態で残ってた記事を公開します。

MessageやSlackのように入力フィールドとキーボードが被らないようにして複数行入力した時に高さをリサイズして可変にしてみた。
実装はView.frameを直接いじる方法では無くSnapKitを用いたAutoLayoutで対応している。

https://github.com/atsushijike/TextInput

* Xcode9.4.1
* Swift4.1

# 対応項目
* キーボード表示/非表示時に`UITextView`の位置をアニメーションで変更する
* 複数行文字入力した時に`UITextView`の高さを変更する
* `UITextView`にプレースホルダーを表示する

# ハマったポイント
* `UITextView` で複数行入力して高さを変えた時にスクロール位置がおかしくなる問題の解消
* プレースホルダーの左右スペース

# 構造
* window.rootViewController.view
  * UINavigationController.view
      * ViewController.view
          * UITableView（UITableViewじゃなくてもなんでもOK）
          * FooterView
              * MessageTextView（UITextViewのサブクラス）
                  * UIButton（送信ボタン）

# SnapKitの導入
Carthageを使ってSnapKitを導入します。
### Cartfile の記述
```
github "SnapKit/SnapKit"
```
### ビルド＆インストール
```
$ carthage bootstrap --platform iOS
```
### Linked Frameworks and Libraries
1. プロジェクト > ターゲット > Generalを選択
2. Linked Frameworks and Libraries の＋を押下
3. *Add Other* を選択してビルドしたSnapKit.frameworkを選択して追加

### Run Script Phase
1. プロジェクト > ターゲット > Build Phasesを選択
2. ＋を押下して *New Run Script Phase*を追加
3. Shellの内容は以下
```
/usr/local/bin/carthage copy-frameworks
```
4. Input FilesにSnapKit.frameworkを追加
```
$(SRCROOT)/Carthage/Build/iOS/SnapKit.framework
```

# `UITextView` がキーボードと被らないようにする
### Layout
`UITableView` の下に `FooterView` を配置する。

```Swift:ViewController.swift
override func viewDidLoad() {

    ...

    tableView.snp.makeConstraints { (make) in
        make.top.leading.trailing.equalToSuperview()
    }
    footerView.snp.makeConstraints { (make) in
        make.top.equalTo(tableView.snp.bottom)
        make.leading.trailing.equalToSuperview()
        make.bottom.equalToSuperview().inset(bottomInset)
    }
```

`bottomInset` は `FooterView` の 下のマージンを定義している。
`setBottomInset(duration:)` するとアニメーションでレイアウトされる。

```Swift:ViewController.swift
private var bottomInset: CGFloat = 0
```
```Swift:ViewController.swift
private func setBottomInset(_ inset: CGFloat, duration: TimeInterval = 0) {
    bottomInset = inset
    view.layoutIfNeeded()
    footerView.snp.updateConstraints({ (make) in
        make.bottom.equalToSuperview().inset(bottomInset)
    })
    UIView.animate(withDuration: duration, animations: {
        self.view.layoutIfNeeded()
    })
}
```

`UIViewController` の `func viewSafeAreaInsetsDidChange()` が呼ばれたら `view.safeAreaInsets.bottom` をセットしておくことでiPhoneXのホームバーと被らないようにしている。

```Swift:ViewController.swift
override func viewSafeAreaInsetsDidChange() {
    setBottomInset(view.safeAreaInsets.bottom)
}
```

### Notifications
`.UIKeyboardWillShow` と `.UIKeyboardWillHide` をobserveしてキーボードの表示/非表示をハンドリングする。

```Swift:ViewController.swift
override func viewDidLoad() {

    ...

    NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow(notification:)), name: .UIKeyboardWillShow, object: nil)
    NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide(notification:)), name: .UIKeyboardWillHide, object: nil)
```

`UIKeyboardAnimationDurationUserInfoKey` で取得したdurationでアニメーションの長さ、 `UIKeyboardFrameEndUserInfoKey` でキーボード矩形を取得して高さを`setBottomInset(duration:)` する。

```Swift:ViewController.swift
@objc private func keyboardWillShow(notification: Notification) {
    if let duration = notification.userInfo?[UIKeyboardAnimationDurationUserInfoKey] as? Double,
        let rectValue = notification.userInfo?[UIKeyboardFrameEndUserInfoKey] as? NSValue {
        setBottomInset(rectValue.cgRectValue.height, duration: duration)
    }
}

@objc private func keyboardWillHide(notification: Notification) {
    if let duration = notification.userInfo?[UIKeyboardAnimationDurationUserInfoKey] as? Double {
        setBottomInset(view.safeAreaInsets.bottom, duration: duration)
    }
}
```

### Animation
`setBottomInset(duration:)` では `footerView` の `bottom` の制約を `bottomInset` でupdateする。

初めのview.layoutIfNeeded()は未適用の表示を確実に更新するために一度呼んでおくため。

アニメーションは `UIView.animate(withDuration:,animations:)`で`self.view.layoutIfNeeded()`を実行して実現する。

```Swift:ViewController.swift
private func setBottomInset(_ inset: CGFloat, duration: TimeInterval = 0) {
    bottomInset = inset
    view.layoutIfNeeded()
    footerView.snp.updateConstraints({ (make) in
        make.bottom.equalToSuperview().inset(bottomInset)
    })
    UIView.animate(withDuration: duration, animations: {
        self.view.layoutIfNeeded()
    })
}
```

# `UITextView`の高さを可変にする
`UITextViewTextDidChange` をobserveする。 

```Swift:MessageTextView.swift
override init(frame: CGRect, textContainer: NSTextContainer?) {

    ...

    NotificationCenter.default.addObserver(self, selector: #selector(textViewTextDidChange(notification:)), name: .UITextViewTextDidChange, object: nil)

    ...

}
```
`invalidateIntrinsicContentSize()` でレイアウト更新することで内容文字に応じてサイズが自動的に変わるようにしている。
また `isScrollEnabled ` は最大値を超えない時＝高さが内容とフィットしている時はfalse、それ以外はスクロールできないとならないのでtrueにする。これでリサイズされた際のスクロール位置がおかしくなる問題が解消される。falseの場合スクローラブルではなくなるため `intrinsicContentSize` によるサイズ算出が適用されるようだ。

```Swift:MessageTextView.swift
@objc func textViewTextDidChange(notification: Notification) {

    ...

    // レイアウト更新
    invalidateIntrinsicContentSize()
    // 高さが最大ではない場合にスクロール不可にしておく
    isScrollEnabled = estimatedContentSize.height > maximumHeight
}
```
`intrinsicContentSize` は `sizeThatFits()` で算出した高さを返している。

```Swift:MessageTextView.swift
var estimatedContentSize: CGSize {
    return sizeThatFits(CGSize(width: frame.width, height: CGFloat.greatestFiniteMagnitude))
}
```
```Swift:MessageTextView.swift
override var intrinsicContentSize: CGSize {
    let size = estimatedContentSize
    return CGSize(width: size.width, height: min(size.height, maximumHeight))
}
```

# `UITextView` にプレースホルダーを表示する
`MessageTextView` に `placeholderLabel` を定義する。

```Swift:MessageTextView.swift
private let placeholderLabel = UILabel()
```
レイアウトは単に `textContainerInset` に従うようセットすると左右に空きスペースが生じてしまう。


```Swift:MessageTextView.swift
override init(frame: CGRect, textContainer: NSTextContainer?) {

        ...

        placeholderLabel.snp.makeConstraints { (make) in
            make.top.left.right.equalTo(textContainerInset)
        }
    }
```
`UITextViewTextDidChange` をobserveして `placeholderLabel` の表示を切り替えている。

```Swift:MessageTextView.swift
override init(frame: CGRect, textContainer: NSTextContainer?) {

    ...

    NotificationCenter.default.addObserver(self, selector: #selector(textViewTextDidChange(notification:)), name: .UITextViewTextDidChange, object: nil)
```
```Swift:MessageTextView.swift
@objc func textViewTextDidChange(notification: Notification) {
    placeholderLabel.isHidden = !text.isEmpty

    ...

}
```