2
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Posted at

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

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

  • 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
  1. Input FilesにSnapKit.frameworkを追加
$(SRCROOT)/Carthage/Build/iOS/SnapKit.framework

UITextView がキーボードと被らないようにする

Layout

UITableView の下に FooterView を配置する。

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)
    }

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

ViewController.swift
private var bottomInset: CGFloat = 0
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()
    })
}

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

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

Notifications

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

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:) する。

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:) では footerViewbottom の制約を bottomInset でupdateする。

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

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

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する。

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 によるサイズ算出が適用されるようだ。

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

    ...

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

intrinsicContentSizesizeThatFits() で算出した高さを返している。

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

UITextView にプレースホルダーを表示する

MessageTextViewplaceholderLabel を定義する。

MessageTextView.swift
private let placeholderLabel = UILabel()

レイアウトは単に textContainerInset に従うようセットすると左右に空きスペースが生じてしまう。

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

        ...

        placeholderLabel.snp.makeConstraints { (make) in
            make.top.left.right.equalTo(textContainerInset)
        }
    }

UITextViewTextDidChange をobserveして placeholderLabel の表示を切り替えている。

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

    ...

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

    ...

}
2
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?