Files
keyboard/CustomKeyboard/View/KBFunctionView.m
2025-11-12 14:18:56 +08:00

662 lines
29 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.

//
// KBFunctionView.m
// CustomKeyboard
//
// Created by Mac on 2025/10/28.
//
#import "KBFunctionView.h"
#import "KBResponderUtils.h" // 统一查找 UIInputViewController 的工具
#import "KBFunctionBarView.h"
#import "KBFunctionPasteView.h"
#import "KBFunctionTagCell.h"
#import "Masonry.h"
#import <MBProgressHUD.h>
#import "KBFullAccessGuideView.h"
#import "KBFullAccessManager.h"
#import "KBSkinManager.h"
#import "KBURLOpenBridge.h" // 兜底从扩展侧直接尝试 openURL:
#import "KBStreamTextView.h" // 流式文本视图
#import "KBStreamFetcher.h" // 网络流封装
static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId";
@interface KBFunctionView () <UICollectionViewDataSource, UICollectionViewDelegateFlowLayout, KBFunctionBarViewDelegate>
// UI
@property (nonatomic, strong) KBFunctionBarView *barViewInternal;
@property (nonatomic, strong) KBFunctionPasteView *pasteViewInternal;
@property (nonatomic, strong) UICollectionView *collectionViewInternal;
@property (nonatomic, strong) UIView *rightButtonContainer; // 右侧竖排按钮容器
@property (nonatomic, strong) UIButton *pasteButtonInternal;
@property (nonatomic, strong) UIButton *deleteButtonInternal;
@property (nonatomic, strong) UIButton *clearButtonInternal;
@property (nonatomic, strong) UIButton *sendButtonInternal;
// 临时:点击标签后展示的“流式文本视图”与其删除按钮
@property (nonatomic, strong, nullable) KBStreamTextView *streamTextView;
@property (nonatomic, strong, nullable) UIButton *streamDeleteButton;
// 流式测试数据 & 定时器
@property (nonatomic, strong, nullable) NSTimer *streamMockTimer;
@property (nonatomic, strong, nullable) NSArray<NSString *> *streamMockChunks;
@property (nonatomic, assign) NSInteger streamMockIndex;
@property (nonatomic, copy, nullable) NSString *streamMockSource; // 连续文本源(包含 \t 作为分段)
@property (nonatomic, assign) NSInteger streamMockCursor; // 当前光标位置
// 网络流式(封装)
@property (nonatomic, strong, nullable) KBStreamFetcher *streamFetcher;
@property (nonatomic, assign) BOOL streamHasOutput; // 是否已输出过正文(首段去首个 \t 用)
// Data
@property (nonatomic, strong) NSArray<NSString *> *itemsInternal;
// 剪贴板自动检测
@property (nonatomic, strong) NSTimer *pasteboardTimer; // 轮询定时器(轻量、主线程)
@property (nonatomic, assign) NSInteger lastHandledPBCount; // 上次处理过的 changeCount避免重复弹窗
@end
@implementation KBFunctionView
- (instancetype)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
// 背景使用当前主题强调色
[self kb_applyTheme];
[self setupUI];
[self reloadDemoData];
// 初始化剪贴板监控状态
_lastHandledPBCount = [UIPasteboard generalPasteboard].changeCount;
// 监听“完全访问”状态变化,动态启停剪贴板监控,避免在未开完全访问时触发 TCC/XPC 错误日志
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(kb_fullAccessChanged) name:KBFullAccessChangedNotification object:nil];
}
return self;
}
#pragma mark - Theme
- (void)kb_applyTheme {
KBSkinManager *mgr = [KBSkinManager shared];
UIColor *accent = mgr.current.accentColor ?: [UIColor colorWithRed:0.77 green:0.93 blue:0.82 alpha:1.0];
BOOL hasImg = ([mgr currentBackgroundImage] != nil);
self.backgroundColor = hasImg ? [accent colorWithAlphaComponent:0.65] : accent;
}
- (void)dealloc {
[self stopPasteboardMonitor];
[self kb_stopNetworkStreaming];
[self kb_stopMockStreaming];
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
#pragma mark - UI
- (void)setupUI {
// 1. 顶部 Bar
[self addSubview:self.barViewInternal];
[self.barViewInternal mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.right.equalTo(self);
make.top.equalTo(self.mas_top).offset(6);
make.height.mas_equalTo(48);
}];
// 右侧竖排按钮容器
[self addSubview:self.rightButtonContainer];
[self.rightButtonContainer mas_makeConstraints:^(MASConstraintMaker *make) {
make.right.equalTo(self.mas_right).offset(-12);
make.top.equalTo(self.barViewInternal.mas_bottom).offset(8);
make.bottom.equalTo(self.mas_bottom).offset(-10);
make.width.mas_equalTo(72);
}];
// 右侧四个按钮
[self.rightButtonContainer addSubview:self.pasteButtonInternal];
[self.rightButtonContainer addSubview:self.deleteButtonInternal];
[self.rightButtonContainer addSubview:self.clearButtonInternal];
[self.rightButtonContainer addSubview:self.sendButtonInternal];
// 竖向排布:粘贴、删除、清空为等高;发送优先更高,但允许在空间不足时压缩
CGFloat smallH = 44;
CGFloat bigH = 56;
// 原 10 在键盘总高度 276 下容易超出容器,改为 8 以避免 AutoLayout 冲突
CGFloat vSpace = 8;
[self.pasteButtonInternal mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.rightButtonContainer.mas_top);
make.left.right.equalTo(self.rightButtonContainer);
make.height.mas_equalTo(smallH);
}];
[self.deleteButtonInternal mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.pasteButtonInternal.mas_bottom).offset(vSpace);
make.left.right.equalTo(self.rightButtonContainer);
make.height.equalTo(self.pasteButtonInternal);
}];
[self.clearButtonInternal mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.deleteButtonInternal.mas_bottom).offset(vSpace);
make.left.right.equalTo(self.rightButtonContainer);
make.height.equalTo(self.pasteButtonInternal);
}];
[self.sendButtonInternal mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.clearButtonInternal.mas_bottom).offset(vSpace);
make.left.right.equalTo(self.rightButtonContainer);
// 允许在空间不足时缩短到 smallH避免产生约束冲突
make.height.greaterThanOrEqualTo(@(smallH));
make.height.lessThanOrEqualTo(@(bigH));
make.bottom.lessThanOrEqualTo(self.rightButtonContainer.mas_bottom);
}];
// 2. 粘贴区(位于右侧按钮左侧)
[self addSubview:self.pasteViewInternal];
[self.pasteViewInternal mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(self.mas_left).offset(12);
make.right.equalTo(self.rightButtonContainer.mas_left).offset(-12);
make.top.equalTo(self.barViewInternal.mas_bottom).offset(8);
make.height.mas_equalTo(48);
}];
// 3. CollectionView
[self addSubview:self.collectionViewInternal];
[self.collectionViewInternal mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(self.mas_left).offset(12);
make.right.equalTo(self.rightButtonContainer.mas_left).offset(-12);
make.top.equalTo(self.pasteViewInternal.mas_bottom).offset(10);
make.bottom.equalTo(self.mas_bottom).offset(-10);
}];
}
#pragma mark - Data
- (void)reloadDemoData {
// 演示数据(可由外部替换)
self.itemsInternal = @[@"高情商", @"暖味拉扯", @"风趣幽默", @"撩女生", @"社交惬匿", @"情场高手", @"一枚暖男", @"聊天搭子", @"表达爱意", @"更多话术"];
[self.collectionViewInternal reloadData];
}
#pragma mark - UICollectionView
- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section {
return self.itemsInternal.count;
}
- (__kindof UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
KBFunctionTagCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:kKBFunctionTagCellId forIndexPath:indexPath];
cell.titleLabel.text = self.itemsInternal[indexPath.item];
return cell;
}
- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath {
// 三列等宽
CGFloat totalW = collectionView.bounds.size.width;
CGFloat space = 10.0;
NSInteger columns = 3;
CGFloat width = floor((totalW - space * (columns - 1)) / columns);
return CGSizeMake(width, 48);
}
- (CGFloat)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout minimumInteritemSpacingForSectionAtIndex:(NSInteger)section {
return 10.0;
}
- (CGFloat)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout minimumLineSpacingForSectionAtIndex:(NSInteger)section {
return 12.0;
}
- (void)kb_showStreamTextViewIfNeededWithTitle:(NSString *)title {
// 已有则不重复创建
if (self.streamTextView.superview) { return; }
// 隐藏标签列表,使用同一区域展示流式文本
self.collectionViewInternal.hidden = YES;
KBStreamTextView *sv = [[KBStreamTextView alloc] init];
sv.backgroundColor = [UIColor colorWithWhite:1.0 alpha:0.92];
sv.layer.cornerRadius = 12.0; sv.layer.masksToBounds = YES;
// 演示:可选填充一段初始文本
if (title.length > 0) {
[sv appendStreamText:[NSString stringWithFormat:@"%@\t", title]]; // 以制表符结尾,生成一个段落
}
[self addSubview:sv];
[sv mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(self.mas_left).offset(12);
make.right.equalTo(self.rightButtonContainer.mas_left).offset(-12);
make.top.equalTo(self.pasteViewInternal.mas_bottom).offset(10);
make.bottom.equalTo(self.mas_bottom).offset(-10);
}];
self.streamTextView = sv;
// 右上角删除按钮
UIButton *del = [UIButton buttonWithType:UIButtonTypeSystem];
del.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.35];
del.layer.cornerRadius = 14; del.layer.masksToBounds = YES;
del.titleLabel.font = [UIFont systemFontOfSize:13 weight:UIFontWeightSemibold];
[del setTitle:@"删除" forState:UIControlStateNormal];
[del setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
[del addTarget:self action:@selector(kb_onTapStreamDelete) forControlEvents:UIControlEventTouchUpInside];
[sv addSubview:del];
[del mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(sv.mas_top).offset(8);
make.right.equalTo(sv.mas_right).offset(-8);
make.height.mas_equalTo(28);
make.width.mas_greaterThanOrEqualTo(56);
}];
self.streamDeleteButton = del;
// 优先拉取后端测试数据GET失败则回落到本地演示
[self kb_startNetworkStreamingWithSeed:title fallbackToMock:YES];
}
- (void)kb_onTapStreamDelete {
// 关闭并销毁流式视图,恢复标签列表
[self kb_stopNetworkStreaming];
[self kb_stopMockStreaming];
[self.streamDeleteButton removeFromSuperview];
self.streamDeleteButton = nil;
[self.streamTextView removeFromSuperview];
self.streamTextView = nil;
self.collectionViewInternal.hidden = NO;
}
#pragma mark - Mock Streaming
// 生成并按时间片推送模拟流数据到 KBStreamTextView
- (void)kb_startMockStreamingWithSeed:(NSString *)seedTitle {
[self kb_stopMockStreaming];
// 连续文本源(包含 \t 作为“分段”分隔符;\n 为换行),按 1~3 字随机切片推送
NSString *t = seedTitle ?: @"高情商";
self.streamMockSource = [NSString stringWithFormat:
@"选择了标签:%@\n"
"下面演示流式输出,每隔一小段时间来一截。第一段结束。\t"
"第二段开始:这是一个较长的句子,会被拆成多个小块推送,直到遇到分隔符就换段。\n第二段结束。\t"
"最后一段:右上角可随时关闭展示。\n演示完成。\t",
t];
self.streamMockCursor = 0;
self.streamMockChunks = nil; // 废弃数组方式
self.streamMockIndex = 0;
// 定时喂数据(主线程):每次随机 1~3 个字
__weak typeof(self) weakSelf = self;
self.streamMockTimer = [NSTimer scheduledTimerWithTimeInterval:0.20 repeats:YES block:^(NSTimer * _Nonnull timer) {
__strong typeof(weakSelf) self = weakSelf; if (!self) { [timer invalidate]; return; }
if (!self.streamTextView) { [timer invalidate]; return; }
NSInteger total = (NSInteger)self.streamMockSource.length;
if (self.streamMockCursor >= total) {
// 结束:收尾裁剪当前段
[self.streamTextView finishStreaming];
[timer invalidate];
self.streamMockTimer = nil;
return;
}
// 随机 1~3 个字(不跨越结尾),逐段追加
uint32_t step = arc4random_uniform(3) + 1; // 1..3
NSInteger remain = total - self.streamMockCursor;
NSInteger take = MIN((NSInteger)step, remain);
NSString *piece = [self.streamMockSource substringWithRange:NSMakeRange(self.streamMockCursor, take)];
self.streamMockCursor += take;
[self.streamTextView appendStreamText:piece ?: @""];
}];
}
- (void)kb_stopMockStreaming {
[self.streamMockTimer invalidate];
self.streamMockTimer = nil;
self.streamMockChunks = nil;
self.streamMockIndex = 0;
self.streamMockSource = nil;
self.streamMockCursor = 0;
}
#pragma mark - Network Streaming (GET)
// 后端测试地址(内网)。若被 ATS 拦截,请在扩展 target 的 Info.plist 中允许 HTTP 或添加例外域。
static NSString * const kKBStreamDemoURL = @"http://192.168.1.144:7529/api/demo/talk";
- (void)kb_startNetworkStreamingWithSeed:(NSString *)seedTitle fallbackToMock:(BOOL)fallback {
[self kb_stopNetworkStreaming];
if (![[KBFullAccessManager shared] hasFullAccess]) { if (fallback) [self kb_startMockStreamingWithSeed:seedTitle]; return; }
NSURL *url = [NSURL URLWithString:kKBStreamDemoURL];
if (!url) { if (fallback) [self kb_startMockStreamingWithSeed:seedTitle]; return; }
self.streamHasOutput = NO; // 重置首段处理标记
__weak typeof(self) weakSelf = self;
KBStreamFetcher *fetcher = [KBStreamFetcher fetcherWithURL:url];
// 由本类统一做 /t->\t 与首段去 \tfetcher 只负责增量与协议解析
fetcher.disableCompression = YES;
fetcher.acceptEventStream = NO; // 响应头若是 SSE 仍会自动解析
fetcher.treatSlashTAsTab = NO;
fetcher.trimLeadingTabOnce = NO;
fetcher.onChunk = ^(NSString *chunk) {
__strong typeof(weakSelf) self = weakSelf; if (!self) return;
[self kb_appendChunkToStreamView:chunk];
};
fetcher.onFinish = ^(NSError * _Nullable error) {
__strong typeof(weakSelf) self = weakSelf; if (!self) return;
if (error && fallback && !self.streamHasOutput) {
[KBHUD showInfo:@"拉取失败,使用本地演示"]; // 降级
[self kb_startMockStreamingWithSeed:nil];
} else {
[self.streamTextView finishStreaming];
}
};
self.streamFetcher = fetcher;
[self.streamFetcher start];
}
- (void)kb_stopNetworkStreaming {
[self.streamFetcher cancel];
self.streamFetcher = nil;
self.streamHasOutput = NO;
}
#pragma mark - Helpers
/// 统一处理需要输出到 KBStreamTextView 的分片:
/// - 将 "/t" 转为真正的制表符 "\t"
/// - 若这是首段输出且文本起始(允许有前导空白)紧跟一个 "\t",只删除这一个;
/// - 非空才追加到视图,并标记已输出。
- (void)kb_appendChunkToStreamView:(NSString *)chunk {
if (chunk.length == 0 || !self.streamTextView) return;
NSString *text = [chunk stringByReplacingOccurrencesOfString:@"/t" withString:@"\t"];
if (!self.streamHasOutput) {
NSUInteger i = 0; // 跳过前导空白
while (i < text.length) {
unichar c = [text characterAtIndex:i];
if (c == ' ' || c == '\r' || c == '\n') { i++; continue; }
break;
}
if (i < text.length && [text characterAtIndex:i] == '\t') {
NSMutableString *m = [text mutableCopy];
[m deleteCharactersInRange:NSMakeRange(i, 1)];
text = m;
}
}
if (text.length == 0) return;
[self.streamTextView appendStreamText:text];
self.streamHasOutput = YES;
}
// 用户点击功能标签:优先 UL 拉起主App失败再 Scheme两次都失败则提示开启完全访问。
// 若已开启“完全访问”,则直接在键盘侧创建 KBStreamTextView并在其右上角提供删除按钮关闭。
- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath {
// 权限全部打开(键盘已启用 + 完全访问)。在扩展进程中仅需判断“完全访问”。
if ([[KBFullAccessManager shared] hasFullAccess]) {
NSString *title = (indexPath.item < self.itemsInternal.count) ? self.itemsInternal[indexPath.item] : @"";
[self kb_showStreamTextViewIfNeededWithTitle:title];
return;
}
[KBHUD showInfo:@"处理中…"];
UIInputViewController *ivc = KBFindInputViewController(self);
if (!ivc) return;
NSString *title = (indexPath.item < self.itemsInternal.count) ? self.itemsInternal[indexPath.item] : @"";
NSString *encodedTitle = [title stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]] ?: @"";
NSURL *ul = [NSURL URLWithString:[NSString stringWithFormat:@"%@?src=functionView&index=%ld&title=%@", KB_UL_LOGIN, (long)indexPath.item, encodedTitle]];
if (!ul) return;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.05 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[ivc.extensionContext openURL:ul completionHandler:^(BOOL ok) {
if (ok) return; // Universal Link 成功
// 统一使用主 App 注册的自定义 Scheme
NSURL *scheme = [NSURL URLWithString:[NSString stringWithFormat:@"%@@//login?src=functionView&index=%ld&title=%@", KB_APP_SCHEME, (long)indexPath.item, encodedTitle]];
[ivc.extensionContext openURL:scheme completionHandler:^(BOOL ok2) {
if (ok2) return;
// 兜底:在用户点击触发的场景下,尝试通过响应链调用 openURL:
// 以提升在“备忘录”等宿主中的成功率。
BOOL bridged = NO;
@try {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wunguarded-availability"
bridged = [KBURLOpenBridge openURLViaResponder:scheme from:self];
#pragma clang diagnostic pop
} @catch (__unused NSException *e) { bridged = NO; }
if (!bridged) {
// 两条路都失败:大概率未开完全访问或宿主拦截。统一交由 Manager 引导。
dispatch_async(dispatch_get_main_queue(), ^{ [[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self]; });
}
}];
}];
});
}
#pragma mark - Button Actions
- (void)onTapPaste {
// 用户点击“粘贴”时才读取剪贴板:
// - iOS16+ 会在跨 App 首次读取时自动弹出系统权限弹窗;
// - iOS15 及以下不会弹窗,直接返回内容;
// 注意:不要在非用户触发的时机主动读取(如 viewDidLoad否则会造成“立刻弹窗”的体验。
UIPasteboard *pb = [UIPasteboard generalPasteboard];
NSString *text = pb.string; // 读取纯文本(可能触发系统粘贴权限弹窗)
if (text.length > 0) {
// 将粘贴内容展示到左侧“粘贴区”的占位文案上
self.pasteView.placeholderLabel.text = text;
// 如果需要多行展示,可按需放开(高度由外部约束决定,默认一行会截断)
// self.pasteView.placeholderLabel.numberOfLines = 0;
} else {
// 无可用文本或用户拒绝了粘贴权限;保持占位文案不变
NSLog(@"粘贴板无可用文本或未授权粘贴");
}
}
#pragma mark - 自动监控剪贴板(复制即弹窗)
// 说明:
// - 仅在视图可见时开启轮询,避免不必要的读取与打扰;
// - 当检测到 changeCount 变化,立即读 pasteboard.string
// * iOS16+:此处会触发系统“是否允许粘贴”弹窗;
// * iOS15不会弹窗直接得到文本
// - 无论允许/拒绝,都把本次 changeCount 记为已处理,避免一直重复询问。
- (void)startPasteboardMonitor {
// 未开启“完全访问”时不做自动读取,避免宿主/系统拒绝并刷错误日志
if (![[KBFullAccessManager shared] hasFullAccess]) return;
if (self.pasteboardTimer) return;
KBWeakSelf
self.pasteboardTimer = [NSTimer scheduledTimerWithTimeInterval:0.5 repeats:YES block:^(NSTimer * _Nonnull timer) {
__strong typeof(weakSelf) self = weakSelf; if (!self) return;
UIPasteboard *pb = [UIPasteboard generalPasteboard];
NSInteger cc = pb.changeCount;
if (cc <= self.lastHandledPBCount) return; // 没有新复制
self.lastHandledPBCount = cc; // 标记已处理,避免重复
// 实际读取触发系统弹窗iOS16+
NSString *text = pb.string;
if (text.length > 0) {
self.pasteView.placeholderLabel.text = text;
}
}];
}
- (void)stopPasteboardMonitor {
[self.pasteboardTimer invalidate];
self.pasteboardTimer = nil;
}
- (void)didMoveToWindow {
[super didMoveToWindow];
[self kb_refreshPasteboardMonitor];
}
- (void)setHidden:(BOOL)hidden {
BOOL wasHidden = self.isHidden;
[super setHidden:hidden];
if (wasHidden != hidden) {
[self kb_refreshPasteboardMonitor];
}
}
// 根据窗口可见性与完全访问状态,统一启停粘贴板监控
- (void)kb_refreshPasteboardMonitor {
BOOL visible = (self.window && !self.isHidden);
if (visible && [[KBFullAccessManager shared] hasFullAccess]) {
[self startPasteboardMonitor];
} else {
[self stopPasteboardMonitor];
}
}
- (void)kb_fullAccessChanged {
dispatch_async(dispatch_get_main_queue(), ^{ [self kb_refreshPasteboardMonitor]; });
}
- (void)onTapDelete {
NSLog(@"点击:删除");
UIInputViewController *ivc = KBFindInputViewController(self);
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
[proxy deleteBackward];
}
- (void)onTapClear {
NSLog(@"点击:清空");
// 连续删除:仅清空光标之前的输入(不改动 pasteView 的内容)
UIInputViewController *ivc = KBFindInputViewController(self);
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
// 逐批读取 documentContextBeforeInput 并删除,避免 50 字符窗口限制带来的残留
NSInteger guard = 0; // 上限保护,避免极端情况下长时间阻塞
while (guard < 10000) {
NSString *before = proxy.documentContextBeforeInput ?: @"";
NSInteger count = before.length;
if (count <= 0) { break; } // 光标前已无内容
for (NSInteger i = 0; i < count; i++) {
[proxy deleteBackward];
}
guard += count;
}
}
- (void)onTapSend {
NSLog(@"点击:发送");
// 发送:插入换行。大多数聊天类 App 会把回车视为“发送”
UIInputViewController *ivc = KBFindInputViewController(self);
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
[proxy insertText:@"\n"];
}
#pragma mark - Lazy
- (KBFunctionBarView *)barViewInternal {
if (!_barViewInternal) {
_barViewInternal = [[KBFunctionBarView alloc] init];
_barViewInternal.delegate = self; // 顶部功能Bar事件下发到本View
}
return _barViewInternal;
}
#pragma mark - KBFunctionBarViewDelegate
- (void)functionBarView:(KBFunctionBarView *)bar didTapLeftAtIndex:(NSInteger)index {
// 将事件继续透传给上层(如键盘控制器),用于切换界面或其它业务
if ([self.delegate respondsToSelector:@selector(functionView:didTapToolActionAtIndex:)]) {
[self.delegate functionView:self didTapToolActionAtIndex:index];
}
}
- (void)functionBarView:(KBFunctionBarView *)bar didTapRightAtIndex:(NSInteger)index {
// 右侧按钮点击,如收藏/宫格等,按需继续向外抛出(这里暂不定义单独协议方法,可在此内部处理或扩展)
}
- (KBFunctionPasteView *)pasteViewInternal {
if (!_pasteViewInternal) {
_pasteViewInternal = [[KBFunctionPasteView alloc] init];
}
return _pasteViewInternal;
}
- (UICollectionView *)collectionViewInternal {
if (!_collectionViewInternal) {
UICollectionViewFlowLayout *layout = [[UICollectionViewFlowLayout alloc] init];
layout.sectionInset = UIEdgeInsetsZero; // 外边距交由约束控制
_collectionViewInternal = [[UICollectionView alloc] initWithFrame:CGRectZero collectionViewLayout:layout];
_collectionViewInternal.backgroundColor = [UIColor clearColor];
_collectionViewInternal.dataSource = self;
_collectionViewInternal.delegate = self;
[_collectionViewInternal registerClass:[KBFunctionTagCell class] forCellWithReuseIdentifier:kKBFunctionTagCellId];
}
return _collectionViewInternal;
}
- (UIView *)rightButtonContainer {
if (!_rightButtonContainer) {
_rightButtonContainer = [[UIView alloc] init];
_rightButtonContainer.backgroundColor = [UIColor clearColor];
}
return _rightButtonContainer;
}
- (UIButton *)buildRightButtonWithTitle:(NSString *)title color:(UIColor *)color {
UIButton *btn = [UIButton buttonWithType:UIButtonTypeSystem];
btn.backgroundColor = color;
btn.layer.cornerRadius = 12.0;
btn.layer.masksToBounds = YES;
btn.titleLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold];
[btn setTitle:title forState:UIControlStateNormal];
[btn setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
return btn;
}
- (UIButton *)pasteButtonInternal {
if (!_pasteButtonInternal) {
_pasteButtonInternal = [self buildRightButtonWithTitle:@"粘贴" color:[UIColor colorWithRed:0.13 green:0.73 blue:0.60 alpha:1.0]];
[_pasteButtonInternal addTarget:self action:@selector(onTapPaste) forControlEvents:UIControlEventTouchUpInside];
}
return _pasteButtonInternal;
}
- (UIButton *)deleteButtonInternal {
if (!_deleteButtonInternal) {
// 浅灰底深色文字,更接近截图里“删除”样式
_deleteButtonInternal = [UIButton buttonWithType:UIButtonTypeSystem];
_deleteButtonInternal.backgroundColor = [UIColor colorWithWhite:0.92 alpha:1.0];
_deleteButtonInternal.layer.cornerRadius = 12.0;
_deleteButtonInternal.layer.masksToBounds = YES;
_deleteButtonInternal.titleLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold];
[_deleteButtonInternal setTitle:@"删除" forState:UIControlStateNormal];
[_deleteButtonInternal setTitleColor:[UIColor blackColor] forState:UIControlStateNormal];
[_deleteButtonInternal addTarget:self action:@selector(onTapDelete) forControlEvents:UIControlEventTouchUpInside];
}
return _deleteButtonInternal;
}
- (UIButton *)clearButtonInternal {
if (!_clearButtonInternal) {
_clearButtonInternal = [UIButton buttonWithType:UIButtonTypeSystem];
_clearButtonInternal.backgroundColor = [UIColor colorWithWhite:0.92 alpha:1.0];
_clearButtonInternal.layer.cornerRadius = 12.0;
_clearButtonInternal.layer.masksToBounds = YES;
_clearButtonInternal.titleLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold];
[_clearButtonInternal setTitle:@"清空" forState:UIControlStateNormal];
[_clearButtonInternal setTitleColor:[UIColor blackColor] forState:UIControlStateNormal];
[_clearButtonInternal addTarget:self action:@selector(onTapClear) forControlEvents:UIControlEventTouchUpInside];
}
return _clearButtonInternal;
}
- (UIButton *)sendButtonInternal {
if (!_sendButtonInternal) {
_sendButtonInternal = [self buildRightButtonWithTitle:@"发送" color:[UIColor colorWithRed:0.13 green:0.73 blue:0.60 alpha:1.0]];
[_sendButtonInternal addTarget:self action:@selector(onTapSend) forControlEvents:UIControlEventTouchUpInside];
}
return _sendButtonInternal;
}
#pragma mark - Expose
- (UICollectionView *)collectionView { return self.collectionViewInternal; }
- (NSArray<NSString *> *)items { return self.itemsInternal; }
- (KBFunctionBarView *)barView { return self.barViewInternal; }
- (KBFunctionPasteView *)pasteView { return self.pasteViewInternal; }
- (UIButton *)pasteButton { return self.pasteButtonInternal; }
- (UIButton *)deleteButton { return self.deleteButtonInternal; }
- (UIButton *)clearButton { return self.clearButtonInternal; }
- (UIButton *)sendButton { return self.sendButtonInternal; }
#pragma mark - Find Owner Controller
// 工具方法已提取到 KBResponderUtils.h
@end