xyk blog

最近は iOS 開発の記事が多めです。

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が破棄されるまでは解除されず、すべての画面で反応してしまうので、上記のように画面が非表示になるタイミングで通知解除を明示的に行います。

xyk.hatenablog.com

xyk.hatenablog.com