// // 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; // 与 labels 对应的分割线 @property (nonatomic, strong) NSMutableArray *separatorLines; // 每一段对应的“原始文本”(不含前缀编号),与 labels 一一对应 @property (nonatomic, strong) NSMutableArray *segmentTexts; // 文本缓冲:保存尚未遇到分隔符的尾部文本 @property (nonatomic, copy) NSString *buffer; @end static const CGFloat kKBStreamSeparatorHeight = 0.5f; static const CGFloat kKBStreamSeparatorSpacingAbove = 5.0f; static const CGFloat kKBStreamSeparatorSpacingBelow = 10.0f; @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]; _separatorLines = [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 *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]; for (UIView *line in self.separatorLines) { [line removeFromSuperview]; } [self.separatorLines 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]; UIView *separator = [self buildSeparatorLine]; [self.scrollView addSubview:separator]; [self.separatorLines addObject:separator]; [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]; UIView *separator = [self buildSeparatorLine]; [self.scrollView addSubview:separator]; [self.separatorLines addObject:separator]; [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; UIView *separator = (idx < self.separatorLines.count) ? self.separatorLines[idx] : nil; CGFloat separatorTop = y + kKBStreamSeparatorSpacingAbove; if (separator) { separator.hidden = NO; separator.frame = CGRectMake(x, separatorTop, maxLabelWidth, kKBStreamSeparatorHeight); } y = separatorTop + kKBStreamSeparatorHeight; if (idx + 1 < self.labels.count) { y += kKBStreamSeparatorSpacingBelow; } } y += self.interItemSpacing; CGFloat contentHeight = MAX(y, 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 proxy = ivc.textDocumentProxy; if ([proxy conformsToProtocol:@protocol(UITextDocumentProxy)]) { NSString *context = proxy.documentContextBeforeInput ?: @""; for (NSUInteger i = 0; i < context.length; i++) { [proxy deleteBackward]; } if (rawText.length > 0) { [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]; } - (UIView *)buildSeparatorLine { UIView *line = [[UIView alloc] initWithFrame:CGRectZero]; line.backgroundColor = [UIColor colorWithHex:0x02BEAC]; line.hidden = YES; return line; } @end