1
This commit is contained in:
53
CustomKeyboard/View/KBStreamTextView.h
Normal file
53
CustomKeyboard/View/KBStreamTextView.h
Normal file
@@ -0,0 +1,53 @@
|
||||
//
|
||||
// KBStreamTextView.h
|
||||
// KeyBoard
|
||||
//
|
||||
// 一个可滚动的视图,用于接收“流式”文本输入。
|
||||
// 当检测到分隔符(默认: "\t" 制表符)时,会将当前累计的文本作为一个段落
|
||||
// 创建一个新的 UILabel;每个标签支持自动换行和点击事件。
|
||||
// 适用于流式数据逐步到达、按段落追加展示的场景。
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
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
|
||||
264
CustomKeyboard/View/KBStreamTextView.m
Normal file
264
CustomKeyboard/View/KBStreamTextView.m
Normal file
@@ -0,0 +1,264 @@
|
||||
//
|
||||
// KBStreamTextView.m
|
||||
// KeyBoard
|
||||
//
|
||||
// 实现:一个接收“流式文本”的可滚动视图。
|
||||
// 以分隔符(默认:"\t")切分文本,每个分段创建一个 UILabel。
|
||||
// 标签可自动换行并可点击,整体置于 UIScrollView 中以支持滚动。
|
||||
//
|
||||
|
||||
#import "KBStreamTextView.h"
|
||||
|
||||
@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;
|
||||
[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];
|
||||
}
|
||||
}
|
||||
|
||||
- (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
|
||||
@@ -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 = "<group>"; };
|
||||
049FB22A2EC31F8800FAB05D /* KBGenderPickerPopView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBGenderPickerPopView.h; sourceTree = "<group>"; };
|
||||
049FB22B2EC31F8800FAB05D /* KBGenderPickerPopView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBGenderPickerPopView.m; sourceTree = "<group>"; };
|
||||
049FB22D2EC34EB900FAB05D /* KBStreamTextView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBStreamTextView.h; sourceTree = "<group>"; };
|
||||
049FB22E2EC34EB900FAB05D /* KBStreamTextView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBStreamTextView.m; sourceTree = "<group>"; };
|
||||
049FB31B2EC21BCD00FAB05D /* KBMyKeyboardCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBMyKeyboardCell.h; sourceTree = "<group>"; };
|
||||
049FB31C2EC21BCD00FAB05D /* KBMyKeyboardCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBMyKeyboardCell.m; sourceTree = "<group>"; };
|
||||
04A9A67D2EB9E1690023B8F4 /* KBResponderUtils.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBResponderUtils.h; sourceTree = "<group>"; };
|
||||
@@ -748,6 +751,8 @@
|
||||
A1B2C3F22EB35A9900000001 /* KBFullAccessGuideView.m */,
|
||||
04FC95B02EB0B2CC007BD342 /* KBSettingView.h */,
|
||||
04FC95B12EB0B2CC007BD342 /* KBSettingView.m */,
|
||||
049FB22D2EC34EB900FAB05D /* KBStreamTextView.h */,
|
||||
049FB22E2EC34EB900FAB05D /* KBStreamTextView.m */,
|
||||
);
|
||||
path = View;
|
||||
sourceTree = "<group>";
|
||||
@@ -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 */,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user