Files
keyboard/keyBoard/Class/Guard/VC/KBGuideVC.m
2025-11-14 18:24:38 +08:00

403 lines
16 KiB
Objective-C
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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 () <UITableViewDelegate, UITableViewDataSource, UITextFieldDelegate, UIGestureRecognizerDelegate>
@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<NSDictionary *> *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 = @"使用引导";
[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<NSString *> *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 = @"🎉 如您遇到其他问题,可点击在线客服帮您解决~";
[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 ? @"是自己的键盘" : @"❎不是自己的键盘")];
}
/// 当权限满足时,尽力激活输入框,从而触发键盘挂载与输入法检测
- (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 = @"在键盘粘贴对话后,选择回复方式";
_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<NSDictionary *> *)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