// // 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" #import "KBKeyboardMaskView.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; /// 当当前系统键盘不是自家键盘时展示的蒙层(带返回箭头 + GIF) @property (nonatomic, strong, nullable) KBKeyboardMaskView *kbKeyboardMaskView; /// 最近一次已知的键盘高度(用于初次展示蒙层时的 GIF 位置计算) @property (nonatomic, assign) CGFloat kb_currentKeyboardHeight; @property (nonatomic, strong) UIImageView *bgImageView; // 全屏背景图 @end @implementation KBGuideVC - (void)viewDidLoad { [super viewDidLoad]; // self.view.backgroundColor = [UIColor colorWithWhite:0.96 alpha:1.0]; self.view.backgroundColor = UIColor.clearColor; self.kb_navView.backgroundColor = [UIColor clearColor]; self.bgImageView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"my_bg_icon"]]; self.bgImageView.contentMode = UIViewContentModeScaleAspectFill; [self.view insertSubview:self.bgImageView belowSubview:self.kb_navView]; [self.bgImageView mas_makeConstraints:^(MASConstraintMaker *make) { make.edges.equalTo(self.view); }]; [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); // 底部跟随键盘变化 self.inputBarBottom = make.bottom.equalTo(self.view.mas_safeAreaLayoutGuideBottom); }]; [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; // 同步控制引导页视频的播放/暂停:仅在显示时播放 self.permVC.view.hidden = !show; if (show) { [self.permVC kb_resumeGuideVideoIfNeeded]; } else { [self.permVC kb_pauseGuideVideo]; } // 若权限已满足(引导未显示),从设置返回时尝试让输入框成为第一响应者, // 以便立刻触发键盘挂载并检测是否为自家键盘/是否已开启完全访问 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); self.kb_currentKeyboardHeight = kbHeight; 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]; }]; // 更新蒙层内部 GIF 与键盘的相对位置,保证不被遮挡 if (self.kbKeyboardMaskView) { [self.kbKeyboardMaskView updateForKeyboardHeight:kbHeight duration:duration curve:curve]; } } - (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; // 根据是否为自家键盘,更新遮罩层显示/隐藏 [self kb_updateKeyboardMaskForIsMyKeyboard:isMine]; } /// 当权限满足时,尽力激活输入框,从而触发键盘挂载与输入法检测 - (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]; }); } /// 根据当前是否为自家键盘,展示/隐藏键盘指导蒙层 - (void)kb_updateKeyboardMaskForIsMyKeyboard:(BOOL)isMine { // 权限引导显示期间不再额外显示键盘蒙层 if (self.permVC && self.permVC.view.hidden == NO) { if (self.kbKeyboardMaskView) { self.kbKeyboardMaskView.hidden = YES; } return; } BOOL shouldShow = !isMine; if (shouldShow) { // 按需创建并添加蒙层 if (!self.kbKeyboardMaskView) { KBKeyboardMaskView *mask = [[KBKeyboardMaskView alloc] initWithFrame:self.view.bounds]; __weak typeof(self) weakSelf = self; mask.tapHandler = ^{ __strong typeof(weakSelf) self = weakSelf; if (!self) return; // 点击蒙层:在“激活/收起”之间切换 textField 的第一响应状态 if ([self.textField isFirstResponder]) { // 当前是第一响应者 -> 收起键盘 [self.view endEditing:YES]; } else { // 当前不是第一响应者 -> 激活,弹出键盘 [self.textField becomeFirstResponder]; } }; // 左上角返回按钮:退出当前 KBGuideVC [mask.backButton addTarget:self action:@selector(kb_onMaskBack) forControlEvents:UIControlEventTouchUpInside]; self.kbKeyboardMaskView = mask; if (self.permVC && self.permVC.view.superview == self.view) { // 确保权限页在最上层,键盘蒙层位于其下方 [self.view insertSubview:mask belowSubview:self.permVC.view]; } else { [self.view addSubview:mask]; } [mask mas_makeConstraints:^(MASConstraintMaker *make) { make.edges.equalTo(self.view); }]; // 创建时立即根据当前键盘高度调整 GIF 位置,避免首次展示被遮挡 [mask updateForKeyboardHeight:self.kb_currentKeyboardHeight duration:0 curve:UIViewAnimationOptionCurveEaseInOut]; } } if (!self.kbKeyboardMaskView) return; BOOL currentlyVisible = !self.kbKeyboardMaskView.hidden && self.kbKeyboardMaskView.alpha > 0.01; if (shouldShow == currentlyVisible) return; self.kbKeyboardMaskView.hidden = NO; CGFloat targetAlpha = shouldShow ? 1.0 : 0.0; [UIView animateWithDuration:0.25 animations:^{ self.kbKeyboardMaskView.alpha = targetAlpha; } completion:^(BOOL finished) { self.kbKeyboardMaskView.hidden = !shouldShow; }]; } /// 蒙层左上角返回按钮事件:让 KBGuideVC 退出 - (void)kb_onMaskBack { [self.navigationController popViewControllerAnimated:YES]; } #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 clearColor]; _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) { // 1) 点在输入栏区域:不当作“背景点击” if ([touch.view isDescendantOfView:self.inputBar]) { return NO; } // 2) 点在任意 cell(包括 KBGuideTopCell)内部:不当作“背景点击”,避免复制时收起键盘 UIView *v = touch.view; while (v && ![v isKindOfClass:UITableViewCell.class]) { v = v.superview; } if (v) { // 找到了 cell return NO; } } return YES; } // 与其它手势同时识别,避免影响表格滚动/选择 - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer { return YES; } @end