Search code examples
iosswifteventskeyboardkeypad

Detect keyboard height while UIScrollView is scrolled down and the keypad is being interactively dragged


I have a UIScrollView and a UITextView, just like in any messaging / chat app, whenUIScrollView is scrolled down, the keypad interactively being dragged too.

I need to detect keyboard height while UIScrollView is scrolled, I tried UIKeyboardWillChangeFrame observer, but this event is called after scroll tap is released.

Without knowing keyboard height, I am unable to update the UITextView bottom constraint, and I get a gap between the keypad and bottom view @screenshot.

enter image description here

Also attaching screenshot from Viber, that does align the bottom bar when keyboard being dragged from scroll bar, also can be seen in WhatsApp too.

enter image description here


Solution

  • As of iOS 10, Apple doesn't provide a NSNotification observer to detect the frame change while the keypad is dragged interactively by UIScrollView, UIKeyboardWillChangeFrame and UIKeyboardDidChangeFrame are observed only once releasing tap.

    Anyways, after looking around DAKeyboardControl library, I had the idea to attach UIScrollView.UIPanGestureRecognizer in the UIViewController, so any gesture events that are produced will be handled in UIViewController as well. After screwing around several hours, I got it to work, here is all the code that is necessary for this:

    class ViewController: UIViewController, UIGestureRecognizerDelegate {
    
        fileprivate let collectionView = UICollectionView(frame: .zero)
        private let bottomView = UIView()
        fileprivate var bottomInset: NSLayoutConstraint!
    
        // This holds height of keypad
        private var maxKeypadHeight: CGFloat = 0 {
            didSet {
                self.updateCollectionViewInsets(maxKeypadHeight + self.bottomView.frame.height)
                self.bottomInset.constant = -maxKeypadHeight
            }
        }
    
        private var isListeningKeypadChange = false
    
        override func viewWillAppear(_ animated: Bool) {
            super.viewWillAppear(animated)
    
            NotificationCenter.default.addObserver(self, selector: #selector(keypadWillChange(_:)), name: .UIKeyboardWillChangeFrame, object: nil)
            NotificationCenter.default.addObserver(self, selector: #selector(keypadWillShow(_:)), name: .UIKeyboardWillShow, object: nil)
            NotificationCenter.default.addObserver(self, selector: #selector(keypadWillHide(_:)), name: .UIKeyboardWillHide, object: nil)
            NotificationCenter.default.addObserver(self, selector: #selector(keypadDidHide), name: .UIKeyboardDidHide, object: nil)
        }
    
        override func viewWillDisappear(_ animated: Bool) {
            super.viewWillDisappear(animated)
    
            NotificationCenter.default.removeObserver(self)
        }
    
        func keypadWillShow(_ notification: Notification) {
            guard !self.isListeningKeypadChange, let userInfo = notification.userInfo as? [String : Any],
                let animationDuration = userInfo[UIKeyboardAnimationDurationUserInfoKey] as? TimeInterval,
                let animationCurve = userInfo[UIKeyboardAnimationCurveUserInfoKey] as? UInt,
                let value = userInfo[UIKeyboardFrameEndUserInfoKey] as? NSValue
                else {
                    return
            }
    
            self.maxKeypadHeight = value.cgRectValue.height
    
            let options = UIViewAnimationOptions.beginFromCurrentState.union(UIViewAnimationOptions(rawValue: animationCurve))
            UIView.animate(withDuration: animationDuration, delay: 0, options: options, animations: { [weak self] in
                self?.view.layoutIfNeeded()
                }, completion: { finished in
                    guard finished else { return }
    
                    // Some delay of about 500MS, before ready to listen other keypad events
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
                        self?.beginListeningKeypadChange()
                    }
            })
        }
    
        func handlePanGestureRecognizer(_ pan: UIPanGestureRecognizer) {
            guard self.isListeningKeypadChange, let windowHeight = self.view.window?.frame.height else { return }
    
            let barHeight = self.bottomView.frame.height
            let keypadHeight = abs(self.bottomInset.constant)
            let usedHeight = keypadHeight + barHeight
    
            let dragY = windowHeight - pan.location(in: self.view.window).y
            let newValue = min(dragY < usedHeight ? max(dragY, 0) : dragY, self.maxKeypadHeight)
    
            print("Old: \(keypadHeight)        New: \(newValue)        Drag: \(dragY)        Used: \(usedHeight)")
            guard keypadHeight != newValue else { return }
            self.updateCollectionViewInsets(newValue + barHeight)
            self.bottomInset.constant = -newValue
        }
    
        func keypadWillChange(_ notification: Notification) {
            if self.isListeningKeypadChange, let value = notification.userInfo?[UIKeyboardFrameEndUserInfoKey] as? NSValue {
                self.maxKeypadHeight = value.cgRectValue.height
            }
        }
    
        func keypadWillHide(_ notification: Notification) {
            guard let userInfo = notification.userInfo as? [String : Any] else { return }
    
            self.maxKeypadHeight = 0
    
            var options = UIViewAnimationOptions.beginFromCurrentState
            if let animationCurve = userInfo[UIKeyboardAnimationCurveUserInfoKey] as? UInt {
                options = options.union(UIViewAnimationOptions(rawValue: animationCurve))
            }
    
            let duration = userInfo[UIKeyboardAnimationDurationUserInfoKey] as? TimeInterval
            UIView.animate(withDuration: duration ?? 0, delay: 0, options: options, animations: {
                self.view.layoutIfNeeded()
            }, completion: nil)
        }
    
        func keypadDidHide() {
            self.collectionView.panGestureRecognizer.removeTarget(self, action: nil)
            self.isListeningKeypadChange = false
            if (self.maxKeypadHeight != 0 || self.bottomInset.constant != 0) {
                self.maxKeypadHeight = 0
            }
        }
    
        private func beginListeningKeypadChange() {
            self.isListeningKeypadChange = true
            self.collectionView.panGestureRecognizer.addTarget(self, action: #selector(self.handlePanGestureRecognizer(_:)))
        }
    
        fileprivate func updateCollectionViewInsets(_ value: CGFloat) {
            let insets = UIEdgeInsets(top: 0, left: 0, bottom: value + 8, right: 0)
            self.collectionView.contentInset = insets
            self.collectionView.scrollIndicatorInsets = insets
        }        
    }