UITextField, UITextView 編集時にキーボードで隠れないようにする - Swift Protocol Extension 版
検証環境:
Xcode 11.6
Swift 5.2.4
以前にも同様の記事を書いていますが、UITextField, UITextView を UIScrollView (やそのサブクラス、UICollectionView ・UITableView など)上に配置して、contentInset を変化させることでキーボードで隠れないようにします。
今回はコード共通化のため Swift Protocol Extension を使って切り出し、使い回せるようにしました。
Protocol Extension 内で Selector を使用するためにちょっとした工夫をしていますが、その辺りは前回の記事で詳しく書いてます。
Helper
UITextField, UITextView を探す UIView Extension
extension UIView { func findFirstResponder() -> UIView? { if isFirstResponder { return self } for v in subviews { if let responder = v.findFirstResponder() { return responder } } return nil } }
Keyboard の Notification.userInfo をマッピングする Struct
struct UIKeyboardInfo { let frame: CGRect let animationDuration: TimeInterval let animationCurve: UIView.AnimationOptions init?(info: [AnyHashable : Any]) { guard let frame = (info[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue, let duration = info[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval, let curve = info[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt else { return nil } self.frame = frame animationDuration = duration animationCurve = UIView.AnimationOptions(rawValue: curve) } }
NotificationCenter の引数に渡す Observer 用クラス
class NotificationObserver { let closure: (_ notification: Notification) -> Void init(closure: @escaping (_ notification: Notification) -> Void) { self.closure = closure } @objc func invoke(_ notification: Notification) { closure(notification) } }
Protocol Extension
キーボードの表示・非表示に合わせて UIScrollView.contentInset を変化させる Protocol Extension
fileprivate var keyboardDetectorShowKey: UInt8 = 0 fileprivate var keyboardDetectorHideKey: UInt8 = 0 protocol KeyboardDetector { var scrollView: UIScrollView! { get } } extension KeyboardDetector where Self: UIViewController { func startObservingKeyboardChanges() { let observerShow = NotificationObserver { [weak self] notification in self?.onKeyboardWillShow(notification) } NotificationCenter.default.addObserver( observerShow, selector: #selector(NotificationObserver.invoke(_:)), name: UIResponder.keyboardWillShowNotification, object: nil) objc_setAssociatedObject( self, &keyboardDetectorShowKey, observerShow, .OBJC_ASSOCIATION_RETAIN) let observerHide = NotificationObserver { [weak self] _ in self?.onKeyboardWillHide() } NotificationCenter.default.addObserver( observerHide, selector: #selector(NotificationObserver.invoke(_:)), name: UIResponder.keyboardWillHideNotification, object: nil) objc_setAssociatedObject( self, &keyboardDetectorHideKey, observerHide, .OBJC_ASSOCIATION_RETAIN) } func stopObservingKeyboardChanges() { if let observerShow = objc_getAssociatedObject( self, &keyboardDetectorShowKey) as? NotificationObserver { NotificationCenter.default.removeObserver( observerShow, name: UIResponder.keyboardWillShowNotification, object: nil) } if let observerHide = objc_getAssociatedObject( self, &keyboardDetectorHideKey) as? NotificationObserver { NotificationCenter.default.removeObserver( observerHide, name: UIResponder.keyboardWillHideNotification, object: nil) } } private func onKeyboardWillShow(_ notification: Notification) { guard let userInfo = notification.userInfo, let keyboardInfo = UIKeyboardInfo(info: userInfo), let inputView = view.findFirstResponder() else { return } let inputRect = inputView.convert(inputView.bounds, to: scrollView) let keyboardRect = scrollView.convert(keyboardInfo.frame, from: nil) let offsetY = inputRect.maxY - keyboardRect.minY if offsetY > 0 { let contentOffset = CGPoint(x: scrollView.contentOffset.x, y: scrollView.contentOffset.y + offsetY) scrollView.contentOffset = contentOffset } let contentInset = UIEdgeInsets(top: 0, left: 0, bottom: keyboardInfo.frame.height - view.safeAreaInsets.bottom, right: 0) scrollView.contentInset = contentInset scrollView.scrollIndicatorInsets = contentInset } private func onKeyboardWillHide() { scrollView.contentInset = .zero scrollView.scrollIndicatorInsets = .zero } }
使用方法
ViewController に KeyboardDetector プロトコルを適合させる
class ViewController: UIViewController, KeyboardDetector { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) startObservingKeyboardChanges() } override func viewWillDisappear(_ animated: Bool) { stopObservingKeyboardChanges() super.viewWillDisappear(animated) } @IBOutlet weak var scrollView: UIScrollView! }
通知解除はiOS 9.0以降で不要になりましたが、通知登録した画面が複数表示されている(UINavigationController#pushやUIViewController#presentなどでViewControlerがStackされている)場合はViewControllerが破棄されるまでは解除されず、すべての画面で反応してしまうので、上記のように画面が非表示になるタイミングで通知解除を明示的に行います。