2025-11-11 19:39:33 +08:00
|
|
|
|
//
|
|
|
|
|
|
// KBStreamTextView.m
|
|
|
|
|
|
// KeyBoard
|
|
|
|
|
|
//
|
|
|
|
|
|
// 实现:一个接收“流式文本”的可滚动视图。
|
|
|
|
|
|
// 以分隔符(默认:"\t")切分文本,每个分段创建一个 UILabel。
|
|
|
|
|
|
// 标签可自动换行并可点击,整体置于 UIScrollView 中以支持滚动。
|
|
|
|
|
|
//
|
|
|
|
|
|
|
|
|
|
|
|
#import "KBStreamTextView.h"
|
2025-11-11 21:48:26 +08:00
|
|
|
|
#import "KBResponderUtils.h" // 通过响应链找到 UIInputViewController,并将文本输出到宿主
|
2025-11-11 19:39:33 +08:00
|
|
|
|
|
|
|
|
|
|
@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;
|
2025-11-12 14:36:15 +08:00
|
|
|
|
// 留一点底部余量,避免最后一行视觉上“贴底被遮住”的感觉
|
|
|
|
|
|
_scrollView.contentInset = UIEdgeInsetsMake(0, 0, 6, 0);
|
|
|
|
|
|
_scrollView.scrollIndicatorInsets = _scrollView.contentInset;
|
2025-11-11 19:39:33 +08:00
|
|
|
|
[self addSubview:_scrollView];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-12 15:31:22 +08:00
|
|
|
|
#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];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-11 19:39:33 +08:00
|
|
|
|
#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];
|
|
|
|
|
|
|
2025-11-12 15:31:22 +08:00
|
|
|
|
// 2) 处理每一个分隔符:完成当前段(仅裁剪“末尾空白”)并新建空标签,然后填入下一段的内容
|
2025-11-11 19:39:33 +08:00
|
|
|
|
for (NSUInteger i = 1; i < parts.count; i++) {
|
|
|
|
|
|
// a) 完成当前段:对外观进行最终裁剪(若开启)
|
|
|
|
|
|
UILabel *current = self.labels.lastObject;
|
2025-11-12 15:31:22 +08:00
|
|
|
|
if (self.shouldTrimSegments) { current.text = KBTrimRight(current.text ?: @""); }
|
2025-11-11 19:39:33 +08:00
|
|
|
|
[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 {
|
2025-11-12 15:31:22 +08:00
|
|
|
|
if (self.shouldTrimSegments) { text = KBTrimRight(text ?: @""); }
|
2025-11-11 19:39:33 +08:00
|
|
|
|
// 创建一个可换行、可点击的 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) {
|
2025-11-12 15:31:22 +08:00
|
|
|
|
NSString *trimmed = KBTrimRight(current.text ?: @"");
|
2025-11-11 19:39:33 +08:00
|
|
|
|
current.text = trimmed;
|
|
|
|
|
|
self.buffer = trimmed;
|
|
|
|
|
|
}
|
2025-11-12 14:36:15 +08:00
|
|
|
|
// 无论是否裁剪,都重新布局并滚动到底部,避免最后一段显示不全
|
|
|
|
|
|
[self layoutLabelsForCurrentWidth];
|
|
|
|
|
|
[self scrollToBottomIfNeeded];
|
2025-11-11 19:39:33 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (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);
|
2025-11-12 14:36:15 +08:00
|
|
|
|
// 使用无动画滚动,避免在短时间内多次追加时“最后一行没完全跟上”的错觉
|
|
|
|
|
|
[self.scrollView setContentOffset:bottomOffset animated:NO];
|
2025-11-11 19:39:33 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#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];
|
2025-11-11 21:48:26 +08:00
|
|
|
|
NSString *text = label.text ?: @"";
|
2025-11-11 19:39:33 +08:00
|
|
|
|
if (index != NSNotFound && self.onLabelTap) {
|
2025-11-11 21:48:26 +08:00
|
|
|
|
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];
|
|
|
|
|
|
}
|
2025-11-11 19:39:33 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@end
|