iOS开发基础22-键盘通知全解析:监听、适配与动画同步

iOS 键盘通知全解析:监听、适配与动画同步

键盘弹出/隐藏时需要调整视图位置避免内容被遮挡。本文详细讲解键盘通知的种类、userInfo 字段、坐标系转换、动画同步,以及 Safe Area 和现代 API 的适配。


一、键盘通知种类

系统在键盘状态变化时发送六种通知:

通知名 触发时机
UIKeyboardWillShowNotification 键盘即将显示
UIKeyboardDidShowNotification 键盘显示完毕
UIKeyboardWillHideNotification 键盘即将隐藏
UIKeyboardDidHideNotification 键盘隐藏完毕
UIKeyboardWillChangeFrameNotification 键盘 frame 即将变化(推荐监听)
UIKeyboardDidChangeFrameNotification 键盘 frame 变化完毕

推荐监听 UIKeyboardWillChangeFrameNotification:它覆盖了弹出、隐藏和 frame 变化(如切换输入法、第三方键盘高度变化)所有场景,比单独监听 WillShow/WillHide 更全面。


二、userInfo 字典字段

键盘通知的 userInfo 包含键盘位置、尺寸和动画信息:

键 类型 说明
UIKeyboardFrameBeginUserInfoKey NSValue(CGRect) 动画开始前键盘的 frame(相对于屏幕)
UIKeyboardFrameEndUserInfoKey NSValue(CGRect) 动画结束后键盘的 frame(相对于屏幕)——最常用
UIKeyboardAnimationDurationUserInfoKey NSNumber 动画时长(秒)
UIKeyboardAnimationCurveUserInfoKey NSNumber 动画曲线(值通常为 7,键盘私有曲线)
UIKeyboardIsLocalUserInfoKey NSNumber 是否当前 App 触发的键盘(iOS 9+)
UIKeyboardBoundsUserInfoKey NSValue 键盘 bounds(iOS 3.2 起废弃,不推荐使用)
UIKeyboardCenterBeginUserInfoKey NSValue 动画开始时键盘中心(已废弃)
UIKeyboardCenterEndUserInfoKey NSValue 动画结束时键盘中心(已废弃)

废弃字段仍可获取值,但 Apple 不保证未来版本继续提供,应优先使用 Frame 和 Animation 相关字段。


三、监听与适配键盘

1. 注册与移除通知

最佳实践:在 viewWillAppear: 注册,viewWillDisappear: 移除,避免多个可见控制器同时响应键盘通知。

- (void)viewWillAppear:(BOOL)animated {
    [super viewWillAppear:animated];
    [[NSNotificationCenter defaultCenter] addObserver:self
                                             selector:@selector(keyboardWillChangeFrame:)
                                                 name:UIKeyboardWillChangeFrameNotification
                                               object:nil];
}

- (void)viewWillDisappear:(BOOL)animated {
    [super viewWillDisappear:animated];
    [[NSNotificationCenter defaultCenter] removeObserver:self
                                                    name:UIKeyboardWillChangeFrameNotification
                                                  object:nil];
}

2. 处理键盘 frame 变化(核心)

- (void)keyboardWillChangeFrame:(NSNotification *)note {
    NSDictionary *userInfo = note.userInfo;

    // 1. 获取键盘最终 frame(相对于屏幕/UIWindow 坐标系)
    CGRect keyboardEndFrame = [userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue];

    // 2. 坐标系转换:将键盘 frame 从 window 坐标系转换到 self.view 坐标系
    //    关键:键盘 frame 是相对于屏幕的,不是相对于 self.view 的
    CGRect keyboardFrameInView = [self.view convertRect:keyboardEndFrame fromView:nil];

    // 3. 计算键盘在 self.view 中遮挡的高度
    //    如果 view 覆盖整个屏幕,也可以用 self.view.bounds.size.height - keyboardFrameInView.origin.y
    CGFloat keyboardHeight = CGRectGetMaxY(self.view.bounds) - CGRectGetMinY(keyboardFrameInView);
    // 键盘隐藏时 keyboardHeight 可能为 0 或负数,取 MAX 保护
    keyboardHeight = MAX(0, keyboardHeight);

    // 4. 更新约束(假设 bottomConstraint 是输入框底部到 view 底部的约束)
    self.bottomConstraint.constant = keyboardHeight + 10; // +10 间距

    // 5. 同步键盘动画:获取动画时长和曲线
    NSTimeInterval duration = [userInfo[UIKeyboardAnimationDurationUserInfoKey] doubleValue];
    NSInteger curveValue = [userInfo[UIKeyboardAnimationCurveUserInfoKey] integerValue];
    // 动画曲线值需要左移 16 位才能作为 UIViewAnimationOptions 使用
    UIViewAnimationOptions curve = curveValue << 16;

    [UIView animateWithDuration:duration delay:0 options:curve animations:^{
        [self.view layoutIfNeeded]; // 约束变化后必须调用 layoutIfNeeded 触发布局
    } completion:nil];
}

3. 关键要点说明

要点 说明
坐标系转换 键盘 frame 相对于屏幕(window),必须 convertRect:fromView:nil 转换到 view 坐标系,否则有导航栏/tabBar 时计算错误
动画曲线同步 键盘动画曲线值通常为 7(私有值),需 curveValue << 16 转为 UIViewAnimationOptions,否则动画节奏与键盘不一致
layoutIfNeeded 修改约束后必须在动画 block 中调用 layoutIfNeeded,否则无动画效果
MAX 保护 键盘隐藏或外部键盘连接时,keyboardHeight 可能为 0 或负数,需 MAX(0, height) 保护

4. 让输入框自动上移(ScrollView 方案)

如果输入框在 UIScrollView(或 UITableView/UICollectionView)中,调整 contentInset 更合适:

- (void)keyboardWillChangeFrame:(NSNotification *)note {
    CGRect keyboardEndFrame = [note.userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue];
    CGRect keyboardFrameInView = [self.view convertRect:keyboardEndFrame fromView:nil];
    CGFloat keyboardHeight = MAX(0, CGRectGetMaxY(self.view.bounds) - CGRectGetMinY(keyboardFrameInView));

    // 调整 scrollView 的 contentInset 和 scrollIndicatorInsets
    UIEdgeInsets inset = self.scrollView.contentInset;
    inset.bottom = keyboardHeight;
    self.scrollView.contentInset = inset;
    self.scrollView.scrollIndicatorInsets = inset;

    // 如果第一响应者被遮挡,滚动到可见区域
    UIView *firstResponder = [self findFirstResponderInView:self.view];
    if (firstResponder) {
        CGRect responderFrame = [self.scrollView convertRect:firstResponder.frame fromView:firstResponder.superview];
        [self.scrollView scrollRectToVisible:responderFrame animated:YES];
    }

    NSTimeInterval duration = [note.userInfo[UIKeyboardAnimationDurationUserInfoKey] doubleValue];
    UIViewAnimationOptions curve = [note.userInfo[UIKeyboardAnimationCurveUserInfoKey] integerValue] << 16;
    [UIView animateWithDuration:duration delay:0 options:curve animations:^{
        [self.view layoutIfNeeded];
    } completion:nil];
}

5. 点击屏幕退出键盘

方式一:touchesBegan(简单但有局限)

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    [self.view endEditing:YES];
}

局限:如果 view 上有按钮等可交互控件,触摸事件会被控件拦截,touchesBegan 不会触发。

方式二:UITapGestureRecognizer(推荐)

- (void)viewDidLoad {
    [super viewDidLoad];
    UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(dismissKeyboard)];
    tap.cancelsTouchesInView = NO; // 不拦截按钮等控件的点击
    [self.view addGestureRecognizer:tap];
}

- (void)dismissKeyboard {
    [self.view endEditing:YES];
}

四、userInfo 打印示例

在键盘通知回调中打印 userInfo,可查看实际字段值:

- (void)keyboardWillChangeFrame:(NSNotification *)note {
    NSLog(@"userInfo: %@", note.userInfo);
}

典型输出(iPhone SE,键盘弹出):

UIKeyboardAnimationCurveUserInfoKey = 7;
UIKeyboardAnimationDurationUserInfoKey = "0.25";
UIKeyboardBoundsUserInfoKey = "NSRect: {{0, 0}, {320, 253}}";
UIKeyboardCenterBeginUserInfoKey = "NSPoint: {160, 694.5}";
UIKeyboardCenterEndUserInfoKey = "NSPoint: {160, 441.5}";
UIKeyboardFrameBeginUserInfoKey = "NSRect: {{0, 568}, {320, 253}}";
UIKeyboardFrameEndUserInfoKey = "NSRect: {{0, 315}, {320, 253}}";
UIKeyboardIsLocalUserInfoKey = 1;

UIKeyboardAnimationCurveUserInfoKey = 7 是键盘特有的动画曲线值(UIViewAnimationCurve 枚举仅定义 0~3,7 是私有值),必须左移 16 位后作为 UIViewAnimationOptions 使用。


五、现代 API:UIKeyboardLayoutGuide(iOS 15+)

iOS 15 引入 UIKeyboardLayoutGuide,无需手动监听通知,通过 Auto Layout 自动跟随键盘:

if (@available(iOS 15.0, *)) {
    UILayoutGuide *keyboardGuide = self.view.keyboardLayoutGuide;
    // 输入框底部约束到键盘顶部,自动跟随键盘移动
    [self.inputField.bottomAnchor constraintEqualToAnchor:keyboardGuide.topAnchor constant:-10].active = YES;
    
    // 可选:设置键盘隐藏时的约束(键盘不在屏幕时跟随底部安全区)
    [keyboardGuide setBottomAnchorConstraintEqualToSystemLayoutGuideFollowingKeyboard:YES];
}

UIKeyboardLayoutGuide 内部自动处理键盘通知和动画同步,是 iOS 15+ 的推荐方案。低版本仍需手动监听通知。


六、Swift 版本对照

class ViewController: UIViewController {

    @IBOutlet weak var bottomConstraint: NSLayoutConstraint!

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(keyboardWillChangeFrame(_:)),
            name: UIResponder.keyboardWillChangeFrameNotification,
            object: nil
        )
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        NotificationCenter.default.removeObserver(
            self,
            name: UIResponder.keyboardWillChangeFrameNotification,
            object: nil
        )
    }

    @objc func keyboardWillChangeFrame(_ note: Notification) {
        guard let userInfo = note.userInfo,
              let endFrame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect,
              let duration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval,
              let curveValue = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt else { return }

        // 坐标系转换
        let keyboardFrameInView = view.convert(endFrame, from: nil)
        let keyboardHeight = max(0, view.bounds.maxY - keyboardFrameInView.minY)

        bottomConstraint.constant = keyboardHeight + 10

        // 同步动画曲线
        let options = UIView.AnimationOptions(rawValue: curveValue << 16)
        UIView.animate(withDuration: duration, delay: 0, options: options) {
            self.view.layoutIfNeeded()
        }
    }

    // 点击退出键盘
    override func viewDidLoad() {
        super.viewDidLoad()
        let tap = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard))
        tap.cancelsTouchesInView = false
        view.addGestureRecognizer(tap)
    }

    @objc func dismissKeyboard() {
        view.endEditing(true)
    }
}

七、底层逻辑与注意事项

1. 通知是同步的

NSNotificationCenter 的 postNotification 是同步执行的:在发布通知的线程上,依次调用所有观察者的回调方法,全部执行完毕后才返回。不是异步。因此键盘通知的回调默认在主线程执行(键盘状态变化由主线程触发),可以直接操作 UI。

2. 键盘 window 与层级

键盘位于单独的 UIWindow(UIRemoteKeyboardWindow)层级上,不在 App 的主 window 中。因此键盘的 frame 始终相对于屏幕坐标系,必须转换到当前 view 的坐标系。

3. 第三方键盘与 frame 变化

切换输入法(如系统键盘 ↔ 第三方键盘)、从英文切换到表情键盘时,键盘高度会变化,此时只触发 UIKeyboardWillChangeFrameNotification,不触发 WillShow/WillHide。这也是推荐监听 ChangeFrame 的原因。

4. 安全区域(Safe Area)

iOS 11+ 有安全区域概念。如果约束是相对于 view.safeAreaLayoutGuide.bottomAnchor,计算键盘遮挡高度时需注意安全区域底部 inset,避免重复计算。通常将输入框底部约束到 view.bottomAnchor(而非 safeArea),再用键盘高度调整,最为直接。

5. 常见坑

问题 原因 解决方案
动画与键盘不同步 未使用键盘的动画曲线 curveValue << 16 同步曲线
有导航栏时高度计算错误 键盘 frame 未做坐标系转换 convertRect:fromView:nil
修改约束无动画 未在动画 block 中调用 layoutIfNeeded 动画 block 内调用
多个页面同时响应键盘 在 viewDidLoad 注册、dealloc 才移除 viewWillAppear 注册、viewWillDisappear 移除
切换输入法高度不变 只监听了 WillShow/WillHide 监听 UIKeyboardWillChangeFrameNotification

八、总结

  • 推荐监听 UIKeyboardWillChangeFrameNotification,覆盖弹出、隐藏、frame 变化全部场景。
  • 键盘 frame 相对于屏幕坐标系,必须 convertRect:fromView:nil 转换到当前 view。
  • 同步键盘动画需同时使用 UIKeyboardAnimationDurationUserInfoKey 和 UIKeyboardAnimationCurveUserInfoKey(曲线值左移 16 位)。
  • 修改约束后在动画 block 中调用 layoutIfNeeded 才能触发动画。
  • 通知注册/移除推荐在 viewWillAppear:/viewWillDisappear:,避免多页面冲突。
  • iOS 15+ 可使用 UIKeyboardLayoutGuide 简化适配,无需手动监听通知。
  • 键盘通知是同步的,回调在主线程执行,可直接操作 UI。

posted @ 2015-07-26 23:59  Mr.陳  阅读(1258)  评论(0)    收藏  举报