662 lines
29 KiB
Objective-C
662 lines
29 KiB
Objective-C
//
|
||
// 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 与首段去 \t,fetcher 只负责增量与协议解析
|
||
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
|