diff --git a/CustomKeyboard/View/KBStreamTextView.h b/CustomKeyboard/View/KBStreamTextView.h new file mode 100644 index 0000000..1801d70 --- /dev/null +++ b/CustomKeyboard/View/KBStreamTextView.h @@ -0,0 +1,53 @@ +// +// KBStreamTextView.h +// KeyBoard +// +// 一个可滚动的视图,用于接收“流式”文本输入。 +// 当检测到分隔符(默认: "\t" 制表符)时,会将当前累计的文本作为一个段落 +// 创建一个新的 UILabel;每个标签支持自动换行和点击事件。 +// 适用于流式数据逐步到达、按段落追加展示的场景。 +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +typedef void (^KBStreamTextTapHandler)(NSInteger index, NSString *text); + +@interface KBStreamTextView : UIView + +/// 分段分隔符,默认 "\t"(制表符)。 +@property (nonatomic, copy) NSString *delimiter; + +/// 标签使用的字体,默认系统 16 号。 +@property (nonatomic, strong) UIFont *labelFont; + +/// 标签文本颜色,iOS 13+ 默认 labelColor,低版本默认黑色。 +@property (nonatomic, strong) UIColor *labelTextColor; + +/// 水平内边距(左右留白),默认 12。 +@property (nonatomic, assign) CGFloat contentHorizontalPadding; + +/// 标签间的垂直间距,默认 5。 +@property (nonatomic, assign) CGFloat interItemSpacing; + +/// 标签点击回调,提供被点击的序号与文本。 +@property (nonatomic, copy, nullable) KBStreamTextTapHandler onLabelTap; + +/// 是否裁剪各段落前后的空白/换行,默认 YES。 +@property (nonatomic, assign) BOOL shouldTrimSegments; + +/// 追加流式文本(边输边见): +/// - 实时将未完成段落展示在“当前标签”上; +/// - 当遇到分隔符时,先将当前标签视为“完成段”,可选裁剪空白,再创建一个新的空标签作为下一段的容器。 +- (void)appendStreamText:(NSString *)text; + +/// 清空所有标签并重置内部缓冲。 +- (void)reset; + +/// 结束输入:将当前正在输入的段落视为完成段(按需裁剪),但不会再新建标签。 +- (void)finishStreaming; + +@end + +NS_ASSUME_NONNULL_END diff --git a/CustomKeyboard/View/KBStreamTextView.m b/CustomKeyboard/View/KBStreamTextView.m new file mode 100644 index 0000000..43647ab --- /dev/null +++ b/CustomKeyboard/View/KBStreamTextView.m @@ -0,0 +1,264 @@ +// +// KBStreamTextView.m +// KeyBoard +// +// 实现:一个接收“流式文本”的可滚动视图。 +// 以分隔符(默认:"\t")切分文本,每个分段创建一个 UILabel。 +// 标签可自动换行并可点击,整体置于 UIScrollView 中以支持滚动。 +// + +#import "KBStreamTextView.h" + +@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; + [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]; + } +} + +- (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:YES]; + } +} + +#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]; + if (index != NSNotFound && self.onLabelTap) { + self.onLabelTap(index, label.text ?: @""); + } +} + +@end diff --git a/keyBoard.xcodeproj/project.pbxproj b/keyBoard.xcodeproj/project.pbxproj index 7279c87..c7779fb 100644 --- a/keyBoard.xcodeproj/project.pbxproj +++ b/keyBoard.xcodeproj/project.pbxproj @@ -86,6 +86,7 @@ 049FB2262EC3136D00FAB05D /* KBPersonInfoItemCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 049FB2252EC3136D00FAB05D /* KBPersonInfoItemCell.m */; }; 049FB2292EC31BB000FAB05D /* KBChangeNicknamePopView.m in Sources */ = {isa = PBXBuildFile; fileRef = 049FB2282EC31BB000FAB05D /* KBChangeNicknamePopView.m */; }; 049FB22C2EC31F8800FAB05D /* KBGenderPickerPopView.m in Sources */ = {isa = PBXBuildFile; fileRef = 049FB22B2EC31F8800FAB05D /* KBGenderPickerPopView.m */; }; + 049FB22F2EC34EB900FAB05D /* KBStreamTextView.m in Sources */ = {isa = PBXBuildFile; fileRef = 049FB22E2EC34EB900FAB05D /* KBStreamTextView.m */; }; 049FB31D2EC21BCD00FAB05D /* KBMyKeyboardCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 049FB31C2EC21BCD00FAB05D /* KBMyKeyboardCell.m */; }; 04A9FE0F2EB481100020DB6D /* KBHUD.m in Sources */ = {isa = PBXBuildFile; fileRef = 04FC97082EB31B14007BD342 /* KBHUD.m */; }; 04A9FE132EB4D0D20020DB6D /* KBFullAccessManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 04A9FE112EB4D0D20020DB6D /* KBFullAccessManager.m */; }; @@ -299,6 +300,8 @@ 049FB2282EC31BB000FAB05D /* KBChangeNicknamePopView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBChangeNicknamePopView.m; sourceTree = ""; }; 049FB22A2EC31F8800FAB05D /* KBGenderPickerPopView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBGenderPickerPopView.h; sourceTree = ""; }; 049FB22B2EC31F8800FAB05D /* KBGenderPickerPopView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBGenderPickerPopView.m; sourceTree = ""; }; + 049FB22D2EC34EB900FAB05D /* KBStreamTextView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBStreamTextView.h; sourceTree = ""; }; + 049FB22E2EC34EB900FAB05D /* KBStreamTextView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBStreamTextView.m; sourceTree = ""; }; 049FB31B2EC21BCD00FAB05D /* KBMyKeyboardCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBMyKeyboardCell.h; sourceTree = ""; }; 049FB31C2EC21BCD00FAB05D /* KBMyKeyboardCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBMyKeyboardCell.m; sourceTree = ""; }; 04A9A67D2EB9E1690023B8F4 /* KBResponderUtils.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBResponderUtils.h; sourceTree = ""; }; @@ -748,6 +751,8 @@ A1B2C3F22EB35A9900000001 /* KBFullAccessGuideView.m */, 04FC95B02EB0B2CC007BD342 /* KBSettingView.h */, 04FC95B12EB0B2CC007BD342 /* KBSettingView.m */, + 049FB22D2EC34EB900FAB05D /* KBStreamTextView.h */, + 049FB22E2EC34EB900FAB05D /* KBStreamTextView.m */, ); path = View; sourceTree = ""; @@ -1392,6 +1397,7 @@ A1B2C3E22EB0C0A100000001 /* KBNetworkManager.m in Sources */, 04FC956A2EB05497007BD342 /* KBKeyButton.m in Sources */, 04FC95B22EB0B2CC007BD342 /* KBSettingView.m in Sources */, + 049FB22F2EC34EB900FAB05D /* KBStreamTextView.m in Sources */, 04FC95702EB09516007BD342 /* KBFunctionView.m in Sources */, 04FC956D2EB054B7007BD342 /* KBKeyboardView.m in Sources */, 04FC95672EB0546C007BD342 /* KBKey.m in Sources */, diff --git a/keyBoard/Class/Home/VC/HomeMainVC.m b/keyBoard/Class/Home/VC/HomeMainVC.m index 4a266b6..e8fdf54 100644 --- a/keyBoard/Class/Home/VC/HomeMainVC.m +++ b/keyBoard/Class/Home/VC/HomeMainVC.m @@ -8,6 +8,7 @@ #import "HomeMainVC.h" #import "HomeHeadView.h" #import "KBPanModalView.h" +#import "KBGuideVC.h" // 首次安装指引页 @interface HomeMainVC () @property (nonatomic, strong) HomeHeadView *headView; @@ -43,6 +44,31 @@ [self.simplePanModalView presentInView:self.view]; } +// 在界面可见后做“首次安装”检查,避免在 viewDidLoad 期间 push 引起的转场警告 +- (void)viewDidAppear:(BOOL)animated { + [super viewDidAppear:animated]; + [self kb_showGuideIfFirstLaunch]; +} + +/// 首次安装进入首页时,跳转到 KBGuideVC +- (void)kb_showGuideIfFirstLaunch { + static NSString *const kKBHasLaunchedOnce = @"KBHasLaunchedOnce"; + NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; + if (![ud boolForKey:kKBHasLaunchedOnce]) { + [ud setBool:YES forKey:kKBHasLaunchedOnce]; + [ud synchronize]; + + // 避免重复 push;同时确保在主线程执行 + if (self.navigationController && self.presentedViewController == nil) { + KBGuideVC *vc = [KBGuideVC new]; + vc.hidesBottomBarWhenPushed = YES; + dispatch_async(dispatch_get_main_queue(), ^{ + [self.navigationController pushViewController:vc animated:YES]; + }); + } + } +} + - (void)setupMas{ [self.headView mas_makeConstraints:^(MASConstraintMaker *make) { make.left.right.equalTo(self.view);