Trouble Getting It Up? ⌨️

Why your keyboard animations look slightly off, and how to build a manager that follows it perfectly.

The keyboard, obviously. Get your mind out of the gutter.

You tap a text field and the keyboard slides up. Your input bar slides up with it. Looks fine, right? Look closer. Your bar arrives a little early, or a little late, or takes a slightly different path. It's subtle but it's there, a tiny drift between your animation and the system's that makes the whole thing feel cheap.

That's because the iOS keyboard doesn't use any animation curve you have access to. It uses a private one. And until you match it, your UI will always be chasing the keyboard instead of moving with it.

The Secret Curve

Every keyboard notificationdeveloper.apple.comkeyboardWillShowNotification carries two hidden values: an animation duration and a curve. The duration is straightforward, usually around 0.25 seconds. The curve is where it gets interesting.

iOS passes a raw integer value of 7developer.apple.com for UIView.AnimationCurvedeveloper.apple.comUIView.AnimationCurve. That's not .easeInOut (0), not .easeIn (1), not .easeOut (2). It's an undocumented system curve that produces the specific feel of the keyboard slide. Under the hood it's an overdamped CASpringAnimationleavez.xyz. If you use any standard UIView.AnimationOptionsdeveloper.apple.comUIView.AnimationOptions curve, your animation will drift away from the keyboard mid-flight.

The fix is to extract both values from the notification and feed them directly into UIView.animate:

animateAlongsideKeyboard
func animateAlongsideKeyboard(
    notification: Notification,
    animations: @escaping () -> Void,
    completion: ((Bool) -> Void)? = nil
) {
    let duration = notification.userInfo?[
        UIResponder.keyboardAnimationDurationUserInfoKey
    ] as? TimeInterval ?? 0.25

    let curveRaw = notification.userInfo?[
        UIResponder.keyboardAnimationCurveUserInfoKey
    ] as? UInt ?? 7

    let curveOption = UIView.AnimationOptions(rawValue: curveRaw << 16)

    UIView.animate(
        withDuration: duration,
        delay: 0,
        options: [curveOption, .beginFromCurrentState],
        animations: animations,
        completion: completion
    )
}

That curveRaw << 16 packs the raw value into the bitmask format that UIView.AnimationOptionsdeveloper.apple.comUIView.AnimationOptions expects. The .beginFromCurrentState flag means if the keyboard changes direction mid-animation (the user taps rapidly), your UI picks up from wherever it is instead of jumping.

Now your constraint changes move in perfect lockstep with the system keyboard. Pixel-for-pixel, frame-for-frame.

Building a Keyboard Manager

To use that animation helper, you need keyboard notifications flowing to the right places. Once you're tracking the keyboard in more than one screen, raw NotificationCenter calls everywhere get messy fast. A lightweight singleton with weak observers keeps it clean.

KeyboardManager.swift
import UIKit

protocol KeyboardObserver: AnyObject {
    func keyboardManager(
        _ manager: KeyboardManager,
        keyboardWillTransitionTo height: CGFloat,
        visible: Bool,
        notification: Notification
    )
}

final class KeyboardManager {
    static let shared = KeyboardManager()

    private(set) var currentHeight: CGFloat = 0
    private(set) var isVisible = false

    private var observers: [ObjectIdentifier: WeakObserver] = [:]

    private init() {
        let notificationCenter = NotificationCenter.default
        notificationCenter.addObserver(
            self, selector: #selector(handleKeyboardWillShow),
            name: UIResponder.keyboardWillShowNotification, object: nil
        )
        notificationCenter.addObserver(
            self, selector: #selector(handleKeyboardWillHide),
            name: UIResponder.keyboardWillHideNotification, object: nil
        )
        notificationCenter.addObserver(
            self, selector: #selector(handleKeyboardWillChangeFrame),
            name: UIResponder.keyboardWillChangeFrameNotification, object: nil
        )
    }

    func addObserver(_ observer: KeyboardObserver) {
        let identifier = ObjectIdentifier(observer)
        observers[identifier] = WeakObserver(observer)
    }

    func removeObserver(_ observer: KeyboardObserver) {
        let identifier = ObjectIdentifier(observer)
        observers.removeValue(forKey: identifier)
    }

    @objc private func handleKeyboardWillShow(_ notification: Notification) {
        guard let endFrame = notification.keyboardEndFrame else { return }
        currentHeight = endFrame.height
        isVisible = true
        notifyObservers(notification)
    }

    @objc private func handleKeyboardWillHide(_ notification: Notification) {
        currentHeight = 0
        isVisible = false
        notifyObservers(notification)
    }

    @objc private func handleKeyboardWillChangeFrame(_ notification: Notification) {
        guard let endFrame = notification.keyboardEndFrame,
              endFrame.height > 0, isVisible
        else { return }
        currentHeight = endFrame.height
    }

    private func notifyObservers(_ notification: Notification) {
        observers = observers.filter { $0.value.object != nil }
        for (_, reference) in observers {
            reference.object?.keyboardManager(
                self,
                keyboardWillTransitionTo: currentHeight,
                visible: isVisible,
                notification: notification
            )
        }
    }

    static func animateAlongsideKeyboard(
        from notification: Notification,
        animations: @escaping () -> Void,
        completion: ((Bool) -> Void)? = nil
    ) {
        let duration = notification.userInfo?[
            UIResponder.keyboardAnimationDurationUserInfoKey
        ] as? TimeInterval ?? 0.25

        let curveRaw = notification.userInfo?[
            UIResponder.keyboardAnimationCurveUserInfoKey
        ] as? UInt ?? 7

        let curveOption = UIView.AnimationOptions(rawValue: curveRaw << 16)

        UIView.animate(
            withDuration: duration,
            delay: 0,
            options: [curveOption, .beginFromCurrentState],
            animations: animations,
            completion: completion
        )
    }
}

private final class WeakObserver {
    weak var object: (any KeyboardObserver)?
    init(_ object: any KeyboardObserver) { self.object = object }
}

private extension Notification {
    var keyboardEndFrame: CGRect? {
        userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
    }
}

The WeakObserver wrapper prevents retain cycles — without it the manager keeps every observer alive forever. The notifyObservers method filters out any dead references before calling through, so stale observers get cleaned up automatically.

The animateAlongsideKeyboard(from:) helper lives on the manager as a static method. You don't need any instance state to use it — just the notification.

Following the Keyboard

Here's where it pays off. Conform to KeyboardObserver, and the callback gives you everything: the new height, whether the keyboard is coming or going, and the raw notification for the animation helper.

An input bar that tracks the keyboard perfectly:

ChatViewController.swift
class ChatViewController: UIViewController, KeyboardObserver {
    private var inputBar: UIView!
    private var inputBarBottom: NSLayoutConstraint!

    override func viewDidLoad() {
        super.viewDidLoad()
        KeyboardManager.shared.addObserver(self)
    }

    deinit {
        KeyboardManager.shared.removeObserver(self)
    }

    func keyboardManager(
        _ manager: KeyboardManager,
        keyboardWillTransitionTo height: CGFloat,
        visible: Bool,
        notification: Notification
    ) {
        let bottomOffset = visible ? height - view.safeAreaInsets.bottom : 0
        inputBarBottom.constant = -bottomOffset

        KeyboardManager.animateAlongsideKeyboard(from: notification) {
            self.view.layoutIfNeeded()
        }
    }
}

Update the constraint, match the animation, trigger layout. Three lines. Your input bar now moves on the exact same curve, at the exact same speed, for the exact same duration as the system keyboard. They're glued together.

Adjusting scroll view insets so nothing gets buried:

When the keyboard covers half the screen, your users can't reach content underneath. Fix it by adjusting the content inset in lockstep:

Scroll Inset Tracking
func keyboardManager(
    _ manager: KeyboardManager,
    keyboardWillTransitionTo height: CGFloat,
    visible: Bool,
    notification: Notification
) {
    let bottomInset = visible
        ? height - view.safeAreaInsets.bottom
        : 0

    KeyboardManager.animateAlongsideKeyboard(from: notification) {
        self.tableView.contentInset.bottom = bottomInset
        self.tableView.verticalScrollIndicatorInsets.bottom = bottomInset
    }

    if visible, let selected = tableView.indexPathForSelectedRow {
        tableView.scrollToRow(at: selected, at: .bottom, animated: true)
    }
}

The inset change animates in sync with the keyboard, and we scroll the focused row into view so the user never loses their place.

Coordinating multiple elements at once:

The real power shows when you're moving several things together. Everything inside the animation block inherits the keyboard's timing, so it all moves as one:

Multi-Element Coordination
func keyboardManager(
    _ manager: KeyboardManager,
    keyboardWillTransitionTo height: CGFloat,
    visible: Bool,
    notification: Notification
) {
    let bottomOffset = visible ? height - view.safeAreaInsets.bottom : 0
    inputBarBottom.constant = -bottomOffset

    KeyboardManager.animateAlongsideKeyboard(from: notification) {
        self.view.layoutIfNeeded()
        self.tabBar.transform = visible
            ? CGAffineTransform(translationX: 0, y: self.tabBar.bounds.height)
            : .identity
        self.dimmingOverlay.alpha = visible ? 0.3 : 0
    }
}

Input bar slides up, tab bar slides down out of the way, a dimming overlay fades in behind the input area — all perfectly synchronized with the keyboard.

Reacting Without Animating

Not everything needs to move. Sometimes the keyboard appearing is just a signal to change state. The visible flag lets you react without touching any animation code:

Non-Animated Reactions
func keyboardManager(
    _ manager: KeyboardManager,
    keyboardWillTransitionTo height: CGFloat,
    visible: Bool,
    notification: Notification
) {
    if visible {
        videoPlayer.pause()
        navigationItem.rightBarButtonItem = dismissKeyboardButton
    } else {
        videoPlayer.play()
        navigationItem.rightBarButtonItem = nil
    }
}

Pause a video when the user starts typing. Swap a nav bar button. Toggle a toolbar. The manager just tells you what's happening and you decide what to do with it.

You can also check state at any time without being an observer. KeyboardManager.shared.isVisible and KeyboardManager.shared.currentHeight are always up to date:

if KeyboardManager.shared.isVisible {
    presentPopover(above: KeyboardManager.shared.currentHeight)
}

What's Next

That drift between your animation and the keyboard's? Gone. It was never that hard to fix — you just needed the right curve.

But there's still the cold start problem. The very first keyboard appearance has a noticeable delay, and you can't predict the keyboard height until it's shown up at least once. In Keyboard Cachingiioscraft, we add height caching and a preload trick that makes the first tap feel just as instant as every tap after it.