403 lines
16 KiB
Objective-C
403 lines
16 KiB
Objective-C
//
|
||
// 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 = 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<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 = 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<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
|