Files
keyboard/CustomKeyboard/View/KBStreamTextView.m
2025-12-05 21:54:10 +08:00

362 lines
14 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.

//
// KBStreamTextView.m
// KeyBoard
//
// 实现:一个接收“流式文本”的可滚动视图。
// 以分隔符(默认:"\t")切分文本,每个分段创建一个 UILabel。
// 标签可自动换行并可点击,整体置于 UIScrollView 中以支持滚动。
//
#import "KBStreamTextView.h"
#import "KBResponderUtils.h" // 通过响应链找到 UIInputViewController并将文本输出到宿主
@interface KBStreamTextView ()
// 承载所有标签的滚动容器
@property (nonatomic, strong) UIScrollView *scrollView;
// 已创建的标签集合(顺序即显示顺序)
@property (nonatomic, strong) NSMutableArray<UILabel *> *labels;
// 每一段对应的“原始文本”(不含前缀编号),与 labels 一一对应
@property (nonatomic, strong) NSMutableArray<NSString *> *segmentTexts;
// 文本缓冲:保存尚未遇到分隔符的尾部文本
@property (nonatomic, copy) NSString *buffer;
@end
@implementation KBStreamTextView
- (instancetype)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
[self commonInit];
}
return self;
}
- (instancetype)initWithCoder:(NSCoder *)coder {
if (self = [super initWithCoder:coder]) {
[self commonInit];
}
return self;
}
- (void)commonInit {
_delimiter = @"\t";
_labelFont = [UIFont systemFontOfSize:16.0];
// 统一使用主题色 #02BEAC 作为段落文字颜色
_labelTextColor = [UIColor colorWithRed:2.0/255.0 green:190.0/255.0 blue:172.0/255.0 alpha:1.0];
_contentHorizontalPadding = 12.0;
_interItemSpacing = 5.0; // 标签之间的垂直间距 5pt
_labels = [NSMutableArray array];
_segmentTexts = [NSMutableArray array];
_buffer = @"";
_shouldTrimSegments = YES;
// 初始化滚动视图并填满自身
_scrollView = [[UIScrollView alloc] initWithFrame:self.bounds];
_scrollView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
_scrollView.alwaysBounceVertical = YES;
_scrollView.showsVerticalScrollIndicator = YES;
// 留一点底部余量,避免最后一行视觉上“贴底被遮住”的感觉
_scrollView.contentInset = UIEdgeInsetsMake(0, 0, 6, 0);
_scrollView.scrollIndicatorInsets = _scrollView.contentInset;
[self addSubview:_scrollView];
}
#pragma mark - Helpers
// 仅去掉末尾空白/换行(保留前导空白,避免“段落在完成时向左跳动”)
static inline NSString *KBTrimRight(NSString *s) {
if (s.length == 0) return s;
NSCharacterSet *set = [NSCharacterSet whitespaceAndNewlineCharacterSet];
NSInteger end = (NSInteger)s.length - 1;
while (end >= 0 && [set characterIsMember:[s characterAtIndex:(NSUInteger)end]]) {
end--;
}
if (end < 0) return @"";
return [s substringToIndex:(NSUInteger)end + 1];
}
#pragma mark - Public API
// 边输边见:实时更新当前 label 文本,遇到分隔符就新建下一个 label
- (void)appendStreamText:(NSString *)text {
if (text.length == 0) { return; }
// 若在子线程被调用,切回主线程更新 UI
if (![NSThread isMainThread]) {
__weak typeof(self) weakSelf = self;
dispatch_async(dispatch_get_main_queue(), ^{
[weakSelf appendStreamText:text];
});
return;
}
// 容错:若分隔符为空,退化为直接将所有内容作为一个段落展示
if (self.delimiter.length == 0) {
[self ensureCurrentLabelExists];
self.buffer = [self.buffer stringByAppendingString:text];
// 更新当前段落的展示文本(带编号前缀)
NSUInteger idx = (self.labels.count > 0) ? (self.labels.count - 1) : 0;
[self setContent:self.buffer forSegmentAtIndex:idx];
[self layoutLabelsForCurrentWidth];
[self scrollToBottomIfNeeded];
return;
}
// 确保有一个“进行中”的 label
[self ensureCurrentLabelExists];
// 将传入文本按分隔符切分。parts.count = 分段数;分隔符数量 = parts.count - 1
NSArray<NSString *> *parts = [text componentsSeparatedByString:self.delimiter];
// 1) 先把第一段拼接到当前缓冲并实时显示
self.buffer = [self.buffer stringByAppendingString:parts.firstObject ?: @""];
// 不裁剪,保留实时输入;更新当前段落展示(附带编号前缀)
NSUInteger currentIndex = (self.labels.count > 0) ? (self.labels.count - 1) : 0;
[self setContent:self.buffer forSegmentAtIndex:currentIndex];
[self layoutLabelsForCurrentWidth];
// 2) 处理每一个分隔符:完成当前段(仅裁剪“末尾空白”)并新建空标签,然后填入下一段的内容
for (NSUInteger i = 1; i < parts.count; i++) {
// a) 完成当前段:对外观进行最终裁剪(若开启)
NSString *currentText = (self.segmentTexts.count > currentIndex)
? (self.segmentTexts[currentIndex] ?: @"")
: (self.buffer ?: @"");
if (self.shouldTrimSegments) {
currentText = KBTrimRight(currentText ?: @"");
}
[self setContent:currentText forSegmentAtIndex:currentIndex];
[self layoutLabelsForCurrentWidth];
// b) 新建一个空标签,代表下一个段(即刻创建,哪怕下一段当前为空)
[self createEmptyLabelAsNewSegment];
currentIndex = (self.labels.count > 0) ? (self.labels.count - 1) : 0;
// c) 将该分隔符之后的这段文本作为新段的初始内容(仍旧实时显示,不裁剪)
NSString *piece = parts[i] ?: @"";
self.buffer = piece;
[self setContent:self.buffer forSegmentAtIndex:currentIndex];
[self layoutLabelsForCurrentWidth];
}
[self scrollToBottomIfNeeded];
}
- (void)reset {
for (UILabel *lbl in self.labels) {
[lbl removeFromSuperview];
}
[self.labels removeAllObjects];
[self.segmentTexts removeAllObjects];
self.buffer = @"";
self.scrollView.contentSize = CGSizeMake(self.bounds.size.width, 0);
}
#pragma mark - Layout Helpers
- (void)addLabelForText:(NSString *)text {
if (self.shouldTrimSegments) { text = KBTrimRight(text ?: @""); }
// 创建一个可换行、可点击的 UILabel
UILabel *label = [[UILabel alloc] initWithFrame:CGRectZero];
label.numberOfLines = 0;
label.font = self.labelFont;
label.textColor = self.labelTextColor;
label.userInteractionEnabled = YES; // 允许点击
NSUInteger idx = self.labels.count;
NSString *content = text ?: @"";
label.text = [self displayTextForSegmentIndex:idx content:content];
// 添加点击手势
UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleLabelTap:)];
[label addGestureRecognizer:tap];
[self.scrollView addSubview:label];
[self.labels addObject:label];
// 维护原始文本数组,与 labels 一一对应
if (self.segmentTexts.count <= idx) {
while (self.segmentTexts.count < idx) { [self.segmentTexts addObject:@""]; }
[self.segmentTexts addObject:content];
} else {
self.segmentTexts[idx] = content;
}
// 依据当前宽度立即布局
[self layoutLabelsForCurrentWidth];
// 滚动到底部以展示最新的标签
[self scrollToBottomIfNeeded];
}
#pragma mark - Streaming Helpers
// 确保存在一个可供“进行中输入”的 label不存在则新建空标签
- (void)ensureCurrentLabelExists {
if (self.labels.lastObject) { return; }
[self createEmptyLabelAsNewSegment];
}
// 创建一个空白的新段标签,并追加到滚动视图中
- (void)createEmptyLabelAsNewSegment {
UILabel *label = [[UILabel alloc] initWithFrame:CGRectZero];
label.numberOfLines = 0;
label.font = self.labelFont;
label.textColor = self.labelTextColor;
label.userInteractionEnabled = YES;
NSUInteger idx = self.labels.count;
// 初始为空内容仅展示编号前缀例如“1: ”)
label.text = [self displayTextForSegmentIndex:idx content:@""];
UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleLabelTap:)];
[label addGestureRecognizer:tap];
[self.scrollView addSubview:label];
[self.labels addObject:label];
// 保证 segmentTexts 与 labels 对齐
if (self.segmentTexts.count <= idx) {
while (self.segmentTexts.count < idx) { [self.segmentTexts addObject:@""]; }
[self.segmentTexts addObject:@""];
} else {
self.segmentTexts[idx] = @"";
}
self.buffer = @"";
[self layoutLabelsForCurrentWidth];
}
- (void)finishStreaming {
if (![NSThread isMainThread]) {
__weak typeof(self) weakSelf = self;
dispatch_async(dispatch_get_main_queue(), ^{
[weakSelf finishStreaming];
});
return;
}
if (self.labels.count == 0) { return; }
NSUInteger idx = self.labels.count - 1;
NSString *content = (self.segmentTexts.count > idx)
? (self.segmentTexts[idx] ?: @"")
: (self.buffer ?: @"");
if (self.shouldTrimSegments) {
content = KBTrimRight(content ?: @"");
}
[self setContent:content forSegmentAtIndex:idx];
self.buffer = content;
// 无论是否裁剪,都重新布局并滚动到底部,避免最后一段显示不全
[self layoutLabelsForCurrentWidth];
[self scrollToBottomIfNeeded];
}
- (void)layoutSubviews {
[super layoutSubviews];
// 处理宽度变化(例如旋转/键盘容器大小变化)时的重排
[self layoutLabelsForCurrentWidth];
}
- (void)layoutLabelsForCurrentWidth {
CGFloat width = self.bounds.size.width;
if (width <= 0) { return; }
CGFloat x = self.contentHorizontalPadding;
CGFloat maxLabelWidth = MAX(0.0, width - 2.0 * self.contentHorizontalPadding);
CGFloat y = self.interItemSpacing; // 顶部预留同等间距
for (NSUInteger idx = 0; idx < self.labels.count; idx++) {
UILabel *label = self.labels[idx];
CGSize size = [self sizeForText:label.text font:label.font maxWidth:maxLabelWidth];
label.frame = CGRectMake(x, y, maxLabelWidth, size.height);
y += size.height;
// 标签间距:下一个标签位于上一个标签下方 5pt
if (idx + 1 < self.labels.count) {
y += self.interItemSpacing;
}
}
CGFloat contentHeight = MAX(y + self.interItemSpacing, self.bounds.size.height + 1.0);
self.scrollView.contentSize = CGSizeMake(width, contentHeight);
}
- (CGSize)sizeForText:(NSString *)text font:(UIFont *)font maxWidth:(CGFloat)maxWidth {
if (text.length == 0) {
// 空文本段仍保留一行高度,保证可点击区域
return CGSizeMake(maxWidth, font.lineHeight);
}
CGRect rect = [text boundingRectWithSize:CGSizeMake(maxWidth, CGFLOAT_MAX)
options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading
attributes:@{NSFontAttributeName: font}
context:nil];
// 向上取整,避免像素裁切
return CGSizeMake(maxWidth, ceil(rect.size.height));
}
- (void)scrollToBottomIfNeeded {
CGFloat height = self.scrollView.bounds.size.height;
CGFloat contentHeight = self.scrollView.contentSize.height;
if (contentHeight > height && height > 0) {
CGPoint bottomOffset = CGPointMake(0, contentHeight - height);
// 使用无动画滚动,避免在短时间内多次追加时“最后一行没完全跟上”的错觉
[self.scrollView setContentOffset:bottomOffset animated:NO];
}
}
#pragma mark - Tap Handling
- (void)handleLabelTap:(UITapGestureRecognizer *)tap {
UILabel *label = (UILabel *)tap.view;
if (![label isKindOfClass:[UILabel class]]) { return; }
NSInteger index = [self.labels indexOfObject:label];
// 对外与插入宿主输入框时使用“原始内容”(不含编号前缀)
NSString *rawText = (index != NSNotFound && index < (NSInteger)self.segmentTexts.count)
? (self.segmentTexts[(NSUInteger)index] ?: @"")
: (label.text ?: @"");
if (index != NSNotFound && self.onLabelTap) {
self.onLabelTap(index, rawText);
}
// 将文本发送到当前宿主应用的输入框(搜索框/TextView 等)
// 注:键盘扩展无需“完全访问”也可 insertText:
UIInputViewController *ivc = KBFindInputViewController(self);
if (ivc) {
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
if (rawText.length > 0 && [proxy conformsToProtocol:@protocol(UITextDocumentProxy)]) {
[proxy insertText:rawText];
}
}
}
#pragma mark - Segment Helpers
// 根据段索引与原始内容生成展示文本(带 1 起始的编号前缀)
- (NSString *)displayTextForSegmentIndex:(NSUInteger)index content:(NSString *)content {
NSInteger displayIndex = (NSInteger)index + 1;
// 统一把内部的换行折叠为单个空格,避免同一段里出现“断行”的视觉效果
NSString *body = content ?: @"";
body = [body stringByReplacingOccurrencesOfString:@"\r\n" withString:@"\n"];
// 将连续换行压缩为单个换行
while ([body containsString:@"\n\n"]) {
body = [body stringByReplacingOccurrencesOfString:@"\n\n" withString:@"\n"];
}
// 最终把所有换行替换成空格
body = [body stringByReplacingOccurrencesOfString:@"\n" withString:@" "];
if (body.length > 0) {
return [NSString stringWithFormat:@"%ld: %@", (long)displayIndex, body];
} else {
return [NSString stringWithFormat:@"%ld: ", (long)displayIndex];
}
}
// 统一更新某一段的“原始内容”和对应 label 的展示文本
- (void)setContent:(NSString *)content forSegmentAtIndex:(NSUInteger)idx {
if (idx >= self.labels.count) { return; }
NSString *body = content ?: @"";
if (self.segmentTexts.count <= idx) {
while (self.segmentTexts.count < idx) { [self.segmentTexts addObject:@""]; }
[self.segmentTexts addObject:body];
} else {
self.segmentTexts[idx] = body;
}
UILabel *label = self.labels[idx];
label.text = [self displayTextForSegmentIndex:idx content:body];
}
@end