Search code examples
iosswiftautolayoutuikitnslayoutconstraint

How can I attach view to keyboard top edge using constraints on iOS


I have a simple view attached to the bottom of view controller:

let view = UIView()
view.backgroundColor = .gray
view.translatesAutoresizingMaskIntoConstraints = false
let viewBottomPositionConstraint = view.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor)
NSLayoutConstraint.activate([
    view.heightAnchor.constraint(equalToConstant: 100),
    view.leftAnchor.constraint(equalTo: self.view.leftAnchor),
    view.rightAnchor.constraint(equalTo: self.view.rightAnchor),
    viewBottomPositionConstraint
])

I need that view to go up and down when keyboard appears/disappears following keyboard animation


Solution

  • I've created KeyboardNotifier, usage is pretty simple:

    var keyboardNotifier: KeyboardNotifier!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        keyboardNotifier = KeyboardNotifier(parentView: view, constraint: viewBottomPositionConstraint)
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        keyboardNotifier.enabled = true
    
    override func viewDidDisappear(_ animated: Bool) {
        keyboardNotifier.enabled = false
        super.viewDidDisappear(animated)
    }
    

    It should be enabled/disabled in viewWillAppear/viewDidDisappear to prevent interactions on other screens.

    Constraint value gets updated by listening to keyboardWillChangeFrameNotification

    I had to move it out of the animation created by keyboard to prevent glitches, and updating constraint in my own animation in parallel.

    final class KeyboardNotifier {
        var enabled: Bool = true {
            didSet {
                setNeedsUpdateConstraint()
            }
        }
        
        init(
            parentView: UIView,
            constraint: NSLayoutConstraint
        ) {
            self.parentView = parentView
            self.constraint = constraint
            
            baseConstant = constraint.constant
            notificationObserver = NotificationCenter.default
                .addObserver(
                    forName: UIResponder.keyboardWillChangeFrameNotification,
                    object: nil,
                    queue: .main
                ) { [weak self] in
                    self?.keyboardEndFrame = ($0.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue
                    self?.setNeedsUpdateConstraint(animationDuration: UIView.inheritedAnimationDuration)
                }
        }
        
        private weak var parentView: UIView?
        private weak var constraint: NSLayoutConstraint?
        private let baseConstant: CGFloat
        private var notificationObserver: NSObjectProtocol!
        private var keyboardEndFrame: CGRect?
        private var latestAnimationDuration: TimeInterval?
        
        private func setNeedsUpdateConstraint(animationDuration: TimeInterval = 0) {
            guard
                latestAnimationDuration == nil
                || animationDuration > latestAnimationDuration!
                else { return }
            let shouldUpdate = latestAnimationDuration == nil
            latestAnimationDuration = animationDuration
            if shouldUpdate {
                DispatchQueue.main.async {
                    self.updateConstraint()
                }
            }
        }
        
        private func updateConstraint() {
            defer {
                latestAnimationDuration = nil
            }
            guard
                let latestAnimationDuration = latestAnimationDuration,
                enabled,
                let keyboardEndFrame = keyboardEndFrame,
                let parentView = parentView,
                let constraint = constraint
                else { return }
            
            UIView.performWithoutAnimation {
                parentView.layoutIfNeeded()
            }
            let isParentFirstItem = constraint.firstItem is UILayoutGuide || constraint.firstItem === parentView
            let followsLayoutGuide = constraint.firstItem is UILayoutGuide || constraint.secondItem is UILayoutGuide
            let multiplierSign: CGFloat = isParentFirstItem ? 1 : -1
            let screenHeight = UIScreen.main.bounds.height
            if keyboardEndFrame.minY >= screenHeight {
                constraint.constant = baseConstant
            } else {
                let safeAreaInsets = (followsLayoutGuide ? parentView.safeAreaInsets.bottom : 0)
                // if our constraint makes view invisible when keyboard is hidden, we need to ignore it
                let fixedBaseConstant = max(multiplierSign * baseConstant, 0)
                constraint.constant = multiplierSign * (screenHeight - keyboardEndFrame.minY - safeAreaInsets + fixedBaseConstant)
            }
            
            UIView.animate(
                withDuration: latestAnimationDuration,
                delay: 0,
                options: .beginFromCurrentState,
                animations: { parentView.layoutIfNeeded() },
                completion: nil
            )
        }
    }