Keyboard Wrangling

Matching the keyboard's secret animation curve, caching its height, killing the cold start delay, and building one manager that handles all of it.

You tap a text field. Keyboard slides up. Your input bar slides up with it. Looks fine? Look closer. Your bar arrives a little early, or a little late, or takes a slightly different path. There's a drift. Subtle, but it makes the whole thing feel off.

Three things make the iOS keyboard annoying to work with: a secret animation curve, a cold start delay on first launch, and no way to know its height before it shows up. Let's fix all three.

The Secret Curve

Every keyboard notificationdeveloper.apple.comkeyboardWillShowNotification comes with a duration and a curve. Duration is about 0.25s. The curve is where it gets fun.

iOS passes a raw integer value of 7developer.apple.com for UIView.AnimationCurvedeveloper.apple.comUIView.AnimationCurve. Not .easeInOut (0), .easeIn (1), or .easeOut (2). It's actually an overdamped CASpringAnimationleavez.xyz hiding behind an undocumented value. Use any standard curve and your animation drifts away mid-flight.

The fix: pull both values from the notification and feed them into UIView.animate. That curveRaw << 16 packs the raw value into the bitmask format UIView.AnimationOptionsdeveloper.apple.comUIView.AnimationOptions expects.

The Cold Start

Now open your app fresh. Tap a text field. Did you catch that hitch?

The keyboard runs out-of-processdeveloper.apple.comKeyboards and Input for security reasons. First time it's needed, the whole process has to spin up from scratch. After that it's instant. But that first tap? Not great.

First tap without preloading vs with

Joseph Smith (@_jsmth)x.com figured out you can warm the keyboard on launch by briefly making a throwaway text field first responder. The field never actually renders. The keyboard process boots up, measures itself, and goes back to sleep.

UIResponder+PreloadKeyboard.swift
extension UIResponder {
    private static var didPreload = false

    static func preloadKeyboard() {
        guard !didPreload else { return }
        didPreload = true

        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
            guard let scene = UIApplication.shared.connectedScenes
                    .first(where: { $0.activationState == .foregroundActive })
                    as? UIWindowScene,
                  let window = scene.windows.first(where: \.isKeyWindow)
            else { return }

            KeyboardManager.shared.isPreloading = true

            let field = UITextField()
            field.autocorrectionType = .no
            window.addSubview(field)
            field.becomeFirstResponder()
            field.resignFirstResponder()
            field.removeFromSuperview()

            KeyboardManager.shared.isPreloading = false
        }
    }
}

Bonus: this fires a keyboard notification, so we can cache the real height before the user taps anything. Which brings us to the other problem. Sometimes you need the keyboard height before it shows up. Pre-positioning an input bar, sizing a sheet, laying out a form. You can't wait for the notification because the user already sees the jump.

All three problems (the curve, the cold start, the unknown height) get solved by one manager.

The Manager

A singleton that listens for keyboard notifications, caches the height to UserDefaults, gives you an estimatedHeight for pre-layout, and provides an animation helper that matches the system curve exactly. Weak observers keep it clean across multiple screens.

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
    var isPreloading = false

    private var observers: [ObjectIdentifier: WeakObserver] = [:]
    private let heightCacheKey = "app.cachedKeyboardHeight"

    var estimatedHeight: CGFloat {
        let cached = UserDefaults.standard.double(forKey: heightCacheKey)
        return cached > 0 ? cached : 335
    }

    private init() {
        let nc = NotificationCenter.default
        nc.addObserver(self, selector: #selector(handleWillShow),
                       name: UIResponder.keyboardWillShowNotification, object: nil)
        nc.addObserver(self, selector: #selector(handleWillHide),
                       name: UIResponder.keyboardWillHideNotification, object: nil)
        nc.addObserver(self, selector: #selector(handleWillChangeFrame),
                       name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
    }

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

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

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

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

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

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

    private func persistHeight(_ height: CGFloat) {
        guard height > 0 else { return }
        UserDefaults.standard.set(height, forKey: heightCacheKey)
    }

    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

        UIView.animate(
            withDuration: duration, delay: 0,
            options: [UIView.AnimationOptions(rawValue: curveRaw << 16),
                      .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 335pt fallback is a standard iPhone keyboard in portrait on iOS 26. Close enough until real data arrives, and after one appearance you'll have the exact number for that device and language.

Putting It Together

The isPreloading flag in the manager suppresses observer callbacks during preload. Without it, any registered observer would see a phantom show/hide cycle on launch. Kick it off in your SceneDelegate:

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
    UIResponder.preloadKeyboard()
}

Then conform to KeyboardObserver anywhere you need to follow the keyboard. Update constraints, match the animation, trigger layout. Done.

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
    ) {
        inputBarBottom.constant = visible
            ? -(height - view.safeAreaInsets.bottom)
            : 0

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

Your input bar now moves on the exact same curve, at the exact same speed, for the exact same duration as the system keyboard. First tap is instant. Every tap after is instant. You always know the height. Keyboard wrangled.