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(送信ボタン)
- MessageTextView(UITextViewのサブクラス)
- ViewController.view
- UINavigationController.view
SnapKitの導入
Carthageを使ってSnapKitを導入します。
Cartfile の記述
github "SnapKit/SnapKit"
ビルド&インストール
$ carthage bootstrap --platform iOS
Linked Frameworks and Libraries
- プロジェクト > ターゲット > Generalを選択
- Linked Frameworks and Libraries の+を押下
- Add Other を選択してビルドしたSnapKit.frameworkを選択して追加
Run Script Phase
- プロジェクト > ターゲット > Build Phasesを選択
- +を押下して New Run Script Phaseを追加
- Shellの内容は以下
/usr/local/bin/carthage copy-frameworks
- Input FilesにSnapKit.frameworkを追加
$(SRCROOT)/Carthage/Build/iOS/SnapKit.framework
UITextView
がキーボードと被らないようにする
Layout
UITableView
の下に FooterView
を配置する。
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:)
するとアニメーションでレイアウトされる。
private var bottomInset: CGFloat = 0
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のホームバーと被らないようにしている。
override func viewSafeAreaInsetsDidChange() {
setBottomInset(view.safeAreaInsets.bottom)
}
Notifications
.UIKeyboardWillShow
と .UIKeyboardWillHide
をobserveしてキーボードの表示/非表示をハンドリングする。
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:)
する。
@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()
を実行して実現する。
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する。
override init(frame: CGRect, textContainer: NSTextContainer?) {
...
NotificationCenter.default.addObserver(self, selector: #selector(textViewTextDidChange(notification:)), name: .UITextViewTextDidChange, object: nil)
...
}
invalidateIntrinsicContentSize()
でレイアウト更新することで内容文字に応じてサイズが自動的に変わるようにしている。
また isScrollEnabled
は最大値を超えない時=高さが内容とフィットしている時はfalse、それ以外はスクロールできないとならないのでtrueにする。これでリサイズされた際のスクロール位置がおかしくなる問題が解消される。falseの場合スクローラブルではなくなるため intrinsicContentSize
によるサイズ算出が適用されるようだ。
@objc func textViewTextDidChange(notification: Notification) {
...
// レイアウト更新
invalidateIntrinsicContentSize()
// 高さが最大ではない場合にスクロール不可にしておく
isScrollEnabled = estimatedContentSize.height > maximumHeight
}
intrinsicContentSize
は sizeThatFits()
で算出した高さを返している。
var estimatedContentSize: CGSize {
return sizeThatFits(CGSize(width: frame.width, height: CGFloat.greatestFiniteMagnitude))
}
override var intrinsicContentSize: CGSize {
let size = estimatedContentSize
return CGSize(width: size.width, height: min(size.height, maximumHeight))
}
UITextView
にプレースホルダーを表示する
MessageTextView
に placeholderLabel
を定義する。
private let placeholderLabel = UILabel()
レイアウトは単に textContainerInset
に従うようセットすると左右に空きスペースが生じてしまう。
override init(frame: CGRect, textContainer: NSTextContainer?) {
...
placeholderLabel.snp.makeConstraints { (make) in
make.top.left.right.equalTo(textContainerInset)
}
}
UITextViewTextDidChange
をobserveして placeholderLabel
の表示を切り替えている。
override init(frame: CGRect, textContainer: NSTextContainer?) {
...
NotificationCenter.default.addObserver(self, selector: #selector(textViewTextDidChange(notification:)), name: .UITextViewTextDidChange, object: nil)
@objc func textViewTextDidChange(notification: Notification) {
placeholderLabel.isHidden = !text.isEmpty
...
}