// // KBGuideVC.m // keyBoard // // Created by Mac on 2025/10/29. // #import "KBGuideVC.h" #import "KBGuideTopCell.h" #import "KBGuideKFCell.h" #import "KBGuideUserCell.h" #import "KBPermissionViewController.h" #import "KBKeyboardPermissionManager.h" typedef NS_ENUM(NSInteger, KBGuideItemType) { KBGuideItemTypeTop = 0, // 顶部固定卡片 KBGuideItemTypeUser, // 我方消息 KBGuideItemTypeKF // 客服回复 }; @interface KBGuideVC () @property (nonatomic, strong) BaseTableView *tableView; // 列表(继承 BaseTableView) @property (nonatomic, strong) UIView *inputBar; // 底部输入容器 @property (nonatomic, strong) UITextField *textField; // 输入框 @property (nonatomic, strong) MASConstraint *inputBarBottom;// 输入栏底部约束 @property (nonatomic, strong) UITapGestureRecognizer *bgTap;// 点击空白收起键盘 @property (nonatomic, strong) NSMutableArray *items; // 数据源 [{type, text}] /// 权限引导页作为子控制器(用于“同时隐藏”) @property (nonatomic, strong, nullable) KBPermissionViewController *permVC; /// 记录上一次的输入法标识,避免重复提示 @property (nonatomic, copy, nullable) NSString *kb_lastInputModeIdentifier; @end @implementation KBGuideVC - (void)viewDidLoad { [super viewDidLoad]; self.view.backgroundColor = [UIColor colorWithWhite:0.96 alpha:1.0]; // self.title = KBLocalized(@"使用引导"); [self.view addSubview:self.tableView]; [self.view addSubview:self.inputBar]; [self.inputBar addSubview:self.textField]; [self.tableView mas_makeConstraints:^(MASConstraintMaker *make) { make.left.right.equalTo(self.view); make.bottom.equalTo(self.view); make.top.mas_equalTo(KB_NAV_TOTAL_HEIGHT); }]; [self.inputBar mas_makeConstraints:^(MASConstraintMaker *make) { make.left.right.equalTo(self.view); make.height.mas_equalTo(52); // 底部跟随键盘变化 if (@available(iOS 11.0, *)) { self.inputBarBottom = make.bottom.equalTo(self.view.mas_safeAreaLayoutGuideBottom); } else { self.inputBarBottom = make.bottom.equalTo(self.view); } }]; [self.textField mas_makeConstraints:^(MASConstraintMaker *make) { make.left.equalTo(self.inputBar).offset(12); make.right.equalTo(self.inputBar).offset(-12); make.centerY.equalTo(self.inputBar); make.height.mas_equalTo(36); }]; // 初始只有固定 Top [self.items addObject:@{ @"type": @(KBGuideItemTypeTop), @"text": @"" }]; [self.tableView reloadData]; // 键盘监听 [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(kb_keyboardWillChange:) name:UIKeyboardWillChangeFrameNotification object:nil]; // 点击空白收起键盘(不干扰 cell 的点击/滚动) self.bgTap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(kb_didTapBackground)]; self.bgTap.cancelsTouchesInView = NO; self.bgTap.delegate = self; [self.tableView addGestureRecognizer:self.bgTap]; // 监听应用回到前台/变为活跃:用于从设置返回时再次校验权限 [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(kb_checkKeyboardPermission) name:UIApplicationDidBecomeActiveNotification object:nil]; // 监听输入法切换(系统未公开常量,使用字符串名以兼容不同系统版本) NSArray *modeNotiNames = @[ @"UITextInputCurrentInputModeDidChangeNotification", @"UITextInputCurrentInputModeDidChange" ]; for (NSString *n in modeNotiNames) { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(kb_inputModeDidChange:) name:n object:nil]; } // 提前创建并铺满权限引导页(默认隐藏),避免后续显示时出现布局进场感 [self kb_preparePermissionOverlayIfNeeded]; } - (void)dealloc { [[NSNotificationCenter defaultCenter] removeObserver:self]; } - (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; // 每次进入页面都校验一次(包括从其它页面返回) [self kb_checkKeyboardPermission]; // 仅在“权限已满足/引导未显示”时再去判断当前键盘是否为自家扩展 BOOL permissionReady = (self.permVC && self.permVC.view.hidden == YES); if (permissionReady) { // 不要在未成为第一响应者时立即判断,避免拿不到 textInputMode 导致“误判不是自己的键盘” if (![self.textField isFirstResponder]) { [self.textField becomeFirstResponder]; } // 尝试在下一轮主线程循环评估一次;若键盘尚未完全弹出,会在 // UIKeyboardWillChangeFrame 或输入法切换通知里再次评估 dispatch_async(dispatch_get_main_queue(), ^{ [self kb_evaluateCurrentInputModeAndNotifyIfNeeded]; }); } } /// 校验键盘权限: /// - 未启用或已启用但拒绝完全访问 => 弹出引导页 /// - 已满足条件且正在展示引导页 => 关闭引导页 - (void)kb_checkKeyboardPermission { KBKeyboardPermissionManager *mgr = [KBKeyboardPermissionManager shared]; BOOL enabled = [mgr isKeyboardEnabled]; KBFARecord fa = [mgr lastKnownFullAccess]; BOOL needGuide = (!enabled) || (enabled && fa == KBFARecordDenied); [self kb_preparePermissionOverlayIfNeeded]; BOOL show = needGuide; // [UIView performWithoutAnimation:^{ self.permVC.view.hidden = !show; // }]; // 若权限已满足(引导未显示),从设置返回时尝试让输入框成为第一响应者, // 以便立刻触发键盘挂载并检测是否为自家键盘/是否已开启完全访问 if (!show) { [self kb_tryActivateTextFieldIfReady]; } } /// 提前创建权限引导页覆盖层(仅一次) - (void)kb_preparePermissionOverlayIfNeeded { if (self.permVC) return; KBPermissionViewController *guide = [KBPermissionViewController new]; // guide.modalPresentationStyle = UIModalPresentationFullScreen; // 仅用于内部布局,不会真正 present KBWeakSelf; guide.backHandler = ^{ [weakSelf.navigationController popViewControllerAnimated:YES]; }; self.permVC = guide; guide.backButton.hidden = true; [self addChildViewController:guide]; [self.view addSubview:guide.view]; // [guide.view mas_makeConstraints:^(MASConstraintMaker *make) { make.edges.equalTo(self.view); }]; [guide didMoveToParentViewController:self]; guide.view.hidden = YES; // 初始隐藏 } - (void)kb_didTapBackground { // 结束编辑,隐藏键盘 [self.view endEditing:YES]; } #pragma mark - Actions // 发送:回车发送一条消息,随后插入固定的客服回复 - (BOOL)textFieldShouldReturn:(UITextField *)textField { NSString *text = [textField.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; if (text.length == 0) { return NO; } // 1. 插入我方消息 [self.items addObject:@{ @"type": @(KBGuideItemTypeUser), @"text": text ?: @"" }]; // 2. 紧跟一条固定客服消息 NSString *reply = KBLocalized(@"🎉 If you run into any other issues, tap Online Support to get help~"); [self.items addObject:@{ @"type": @(KBGuideItemTypeKF), @"text": reply }]; // 刷新并滚动到底部 [self.tableView reloadData]; [self scrollToBottomAnimated:YES]; textField.text = @""; return YES; } - (void)kb_keyboardWillChange:(NSNotification *)note { NSDictionary *info = note.userInfo; NSTimeInterval duration = [info[UIKeyboardAnimationDurationUserInfoKey] doubleValue]; UIViewAnimationOptions curve = ([info[UIKeyboardAnimationCurveUserInfoKey] integerValue] << 16); CGRect endFrame = [info[UIKeyboardFrameEndUserInfoKey] CGRectValue]; CGFloat screenH = UIScreen.mainScreen.bounds.size.height; CGFloat kbHeight = MAX(0, screenH - endFrame.origin.y); CGFloat safeBtm = 0; if (@available(iOS 11.0, *)) { safeBtm = self.view.safeAreaInsets.bottom; } // 输入栏距离底部 = -max(kbHeight - 安全区, 0) CGFloat offset = -MAX(kbHeight - safeBtm, 0); self.inputBarBottom.offset = offset; [UIView animateWithDuration:duration delay:0 options:curve animations:^{ UIEdgeInsets inset = self.tableView.contentInset; inset.bottom = 52 + MAX(kbHeight - safeBtm, 0); self.tableView.contentInset = inset; // self.tableView.scrollIndicatorInsets = inset; } completion:^(BOOL finished) { [self scrollToBottomAnimated:YES]; // 键盘位置变化后,尝试检测是否发生了输入法切换 [self kb_evaluateCurrentInputModeAndNotifyIfNeeded]; }]; } - (void)scrollToBottomAnimated:(BOOL)animated { if (self.items.count == 0) return; NSInteger last = self.items.count - 1; NSIndexPath *ip = [NSIndexPath indexPathForRow:last inSection:0]; if (last >= 0) { [self.tableView scrollToRowAtIndexPath:ip atScrollPosition:UITableViewScrollPositionBottom animated:animated]; } } #pragma mark - Keyboard Detection /// 判断当前激活在本页输入框上的系统键盘是否为我方扩展键盘 /// 说明: /// - 依赖 `UITextField.textInputMode` 获取当前输入法; /// - 通过 KVC 读取其 `identifier`(系统未公开的属性),再与扩展的 bundle id 比较; /// - 仅在 `textField` 为第一响应者、且软键盘弹出时才有意义。 - (BOOL)kb_isMyExtensionKeyboardSelected { UITextInputMode *mode = self.textField.textInputMode; if (!mode) { return NO; } NSString *identifier = nil; @try { // 私有字段:仅用于判断是否为自家扩展;与 App 内其它用法保持一致 identifier = [mode valueForKey:@"identifier"]; } @catch (__unused NSException *e) { identifier = nil; } if (![identifier isKindOfClass:[NSString class]]) { return NO; } return [identifier rangeOfString:KB_KEYBOARD_EXTENSION_BUNDLE_ID].location != NSNotFound; } - (void)kb_inputModeDidChange:(NSNotification *)note { // 用户从地球键切换输入法时调用 [self kb_evaluateCurrentInputModeAndNotifyIfNeeded]; } /// 读取当前输入法 identifier(可能为 nil) - (NSString *)kb_currentInputModeIdentifier { UITextInputMode *mode = self.textField.textInputMode; if (!mode) return nil; NSString *identifier = nil; @try { identifier = [mode valueForKey:@"identifier"]; } @catch (__unused NSException *e) { identifier = nil; } return [identifier isKindOfClass:NSString.class] ? identifier : nil; } /// 若输入法发生变化,则立刻提示是否为自家键盘 - (void)kb_evaluateCurrentInputModeAndNotifyIfNeeded { // 仅在本页输入框处于编辑状态时判断,避免误触发 if (![self.textField isFirstResponder]) return; // 若权限引导正在显示,不弹提示(未满足前置条件) if (self.permVC && self.permVC.view.hidden == NO) return; NSString *currId = [self kb_currentInputModeIdentifier]; if (currId.length == 0) return; if ([self.kb_lastInputModeIdentifier isEqualToString:currId]) return; // 去抖 self.kb_lastInputModeIdentifier = currId; BOOL isMine = [currId rangeOfString:KB_KEYBOARD_EXTENSION_BUNDLE_ID].location != NSNotFound; [KBHUD showInfo:(isMine ? KBLocalized(@"是自己的键盘") : KBLocalized(@"❎不是自己的键盘"))]; } /// 当权限满足时,尽力激活输入框,从而触发键盘挂载与输入法检测 - (void)kb_tryActivateTextFieldIfReady { // 权限未满足或存在覆盖层时不处理 if (self.permVC && self.permVC.view.hidden == NO) return; // 视图未显示到窗口上时不处理(避免早期调用无效) if (!self.view.window) return; // 若未成为第一响应者,则尝试激活并在下一轮循环评估一次 if (![self.textField isFirstResponder]) { [self.textField becomeFirstResponder]; } dispatch_async(dispatch_get_main_queue(), ^{ [self kb_evaluateCurrentInputModeAndNotifyIfNeeded]; }); } #pragma mark - UITableView - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { return self.items.count; } - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { NSDictionary *it = self.items[indexPath.row]; KBGuideItemType type = [it[@"type"] integerValue]; NSString *text = it[@"text"] ?: @""; if (type == KBGuideItemTypeTop) { KBGuideTopCell *cell = [tableView dequeueReusableCellWithIdentifier:[KBGuideTopCell reuseId]]; if (!cell) cell = [[KBGuideTopCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:[KBGuideTopCell reuseId]]; return cell; } else if (type == KBGuideItemTypeUser) { KBGuideUserCell *cell = [tableView dequeueReusableCellWithIdentifier:[KBGuideUserCell reuseId]]; if (!cell) cell = [[KBGuideUserCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:[KBGuideUserCell reuseId]]; [cell configText:text]; return cell; } else { KBGuideKFCell *cell = [tableView dequeueReusableCellWithIdentifier:[KBGuideKFCell reuseId]]; if (!cell) cell = [[KBGuideKFCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:[KBGuideKFCell reuseId]]; [cell configText:text]; return cell; } } - (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath { return UITableViewAutomaticDimension; } - (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath { return 100; } #pragma mark - Lazy - (BaseTableView *)tableView { if (!_tableView) { _tableView = [[BaseTableView alloc] initWithFrame:CGRectZero style:UITableViewStylePlain]; _tableView.delegate = self; _tableView.dataSource = self; _tableView.separatorStyle = UITableViewCellSeparatorStyleNone; // 无分割线 _tableView.backgroundColor = [UIColor colorWithWhite:0.96 alpha:1.0]; _tableView.rowHeight = UITableViewAutomaticDimension; _tableView.estimatedRowHeight = 120; // 开启自适应高度 _tableView.contentInset = UIEdgeInsetsMake(0, 0, 52 + KB_SafeAreaBottom(), 0); _tableView.scrollIndicatorInsets = _tableView.contentInset; } return _tableView; } - (UIView *)inputBar { if (!_inputBar) { _inputBar = [UIView new]; _inputBar.backgroundColor = [UIColor colorWithWhite:0.96 alpha:1.0]; UIView *bg = [UIView new]; bg.backgroundColor = [UIColor whiteColor]; bg.layer.cornerRadius = 10; bg.layer.masksToBounds = YES; [_inputBar addSubview:bg]; [bg mas_makeConstraints:^(MASConstraintMaker *make) { make.left.equalTo(_inputBar).offset(12); make.right.equalTo(_inputBar).offset(-12); make.top.equalTo(_inputBar).offset(8); make.bottom.equalTo(_inputBar).offset(-8); }]; } return _inputBar; } - (UITextField *)textField { if (!_textField) { _textField = [UITextField new]; _textField.delegate = self; _textField.returnKeyType = UIReturnKeySend; // 回车发送 _textField.font = [UIFont systemFontOfSize:15]; _textField.placeholder = KBLocalized(@"After pasting the conversation in the keyboard, choose a reply style"); _textField.backgroundColor = [UIColor whiteColor]; _textField.layer.cornerRadius = 10; _textField.layer.masksToBounds = YES; UIView *pad = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 10, 36)]; _textField.leftView = pad; _textField.leftViewMode = UITextFieldViewModeAlways; } return _textField; } - (NSMutableArray *)items { if (!_items) { _items = @[].mutableCopy; } return _items; } #pragma mark - UIGestureRecognizerDelegate // 避免点到输入栏触发收起 - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch { if (gestureRecognizer == self.bgTap) { if ([touch.view isDescendantOfView:self.inputBar]) { return NO; } } return YES; } // 与其它手势同时识别,避免影响表格滚动/选择 - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer { return YES; } @end