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