Files
keyboard/CustomKeyboard/View/KBStreamTextView.m
2025-11-12 14:36:15 +08:00

282 lines
10 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;
// 文本缓冲:保存尚未遇到分隔符的尾部文本
@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];
if (@available(iOS 13.0, *)) {
_labelTextColor = [UIColor labelColor];
} else {
_labelTextColor = [UIColor blackColor];
}
_contentHorizontalPadding = 12.0;
_interItemSpacing = 5.0; // 标签之间的垂直间距 5pt
_labels = [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 - 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];
self.labels.lastObject.text = self.buffer;
[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 ?: @""];
self.labels.lastObject.text = self.buffer; // 不裁剪,保留实时输入
[self layoutLabelsForCurrentWidth];
// 2) 处理每一个分隔符:完成当前段(可选裁剪)并新建空标签,然后填入下一段的内容
for (NSUInteger i = 1; i < parts.count; i++) {
// a) 完成当前段:对外观进行最终裁剪(若开启)
UILabel *current = self.labels.lastObject;
if (self.shouldTrimSegments) {
NSString *trimmed = [current.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
current.text = trimmed;
}
[self layoutLabelsForCurrentWidth];
// b) 新建一个空标签,代表下一个段(即刻创建,哪怕下一段当前为空)
[self createEmptyLabelAsNewSegment];
// c) 将该分隔符之后的这段文本作为新段的初始内容(仍旧实时显示,不裁剪)
NSString *piece = parts[i];
self.buffer = piece ?: @"";
self.labels.lastObject.text = self.buffer;
[self layoutLabelsForCurrentWidth];
}
[self scrollToBottomIfNeeded];
}
- (void)reset {
for (UILabel *lbl in self.labels) {
[lbl removeFromSuperview];
}
[self.labels removeAllObjects];
self.buffer = @"";
self.scrollView.contentSize = CGSizeMake(self.bounds.size.width, 0);
}
#pragma mark - Layout Helpers
- (void)addLabelForText:(NSString *)text {
if (self.shouldTrimSegments) {
text = [text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
}
// 创建一个可换行、可点击的 UILabel
UILabel *label = [[UILabel alloc] initWithFrame:CGRectZero];
label.numberOfLines = 0;
label.font = self.labelFont;
label.textColor = self.labelTextColor;
label.userInteractionEnabled = YES; // 允许点击
label.text = text;
// 添加点击手势
UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleLabelTap:)];
[label addGestureRecognizer:tap];
[self.scrollView addSubview:label];
[self.labels addObject:label];
// 依据当前宽度立即布局
[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;
label.text = @"";
UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleLabelTap:)];
[label addGestureRecognizer:tap];
[self.scrollView addSubview:label];
[self.labels addObject:label];
self.buffer = @"";
[self layoutLabelsForCurrentWidth];
}
- (void)finishStreaming {
if (![NSThread isMainThread]) {
__weak typeof(self) weakSelf = self;
dispatch_async(dispatch_get_main_queue(), ^{
[weakSelf finishStreaming];
});
return;
}
UILabel *current = self.labels.lastObject;
if (!current) { return; }
if (self.shouldTrimSegments) {
NSString *trimmed = [current.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
current.text = trimmed;
self.buffer = trimmed;
}
// 无论是否裁剪,都重新布局并滚动到底部,避免最后一段显示不全
[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 *text = label.text ?: @"";
if (index != NSNotFound && self.onLabelTap) {
self.onLabelTap(index, text);
}
// 将文本发送到当前宿主应用的输入框(搜索框/TextView 等)
// 注:键盘扩展无需“完全访问”也可 insertText:
UIInputViewController *ivc = KBFindInputViewController(self);
if (ivc) {
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
if (text.length > 0 && [proxy conformsToProtocol:@protocol(UITextDocumentProxy)]) {
[proxy insertText:text];
}
}
}
@end