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 notification 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 7 for UIView.AnimationCurve. Not .easeInOut (0), .easeIn (1), or .easeOut (2). It's actually an overdamped CASpringAnimation 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.AnimationOptions expects.
The Cold Start
Now open your app fresh. Tap a text field. Did you catch that hitch?
The keyboard runs out-of-process 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.
Joseph Smith (@_jsmth) 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.
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.
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.
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.