// // KBStreamTextView.m // KeyBoard // // 实现:一个接收“流式文本”的可滚动视图。 // 以分隔符(默认:"\t")切分文本,每个分段创建一个 UILabel。 // 标签可自动换行并可点击,整体置于 UIScrollView 中以支持滚动。 // #import "KBStreamTextView.h" #import "KBResponderUtils.h" // 通过响应链找到 UIInputViewController,并将文本输出到宿主 @interface KBStreamTextView () // 承载所有标签的滚动容器 @property (nonatomic, strong) UIScrollView *scrollView; // 已创建的标签集合(顺序即显示顺序) @property (nonatomic, strong) NSMutableArray *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 *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 proxy = ivc.textDocumentProxy; if (text.length > 0 && [proxy conformsToProtocol:@protocol(UITextDocumentProxy)]) { [proxy insertText:text]; } } } @end