发送文本处理ui和逻辑

This commit is contained in:
2026-01-29 17:56:53 +08:00
parent b556e6841d
commit d0c5cada35
3 changed files with 193 additions and 81 deletions

View File

@@ -20,7 +20,7 @@
#import "KBAIMessageVC.h"
#import <Masonry/Masonry.h>
@interface KBAIHomeVC () <UICollectionViewDelegate, UICollectionViewDataSource, KBVoiceToTextManagerDelegate, KBVoiceRecordManagerDelegate, UIGestureRecognizerDelegate, KBChatLimitPopViewDelegate>
@interface KBAIHomeVC () <UICollectionViewDelegate, UICollectionViewDataSource, KBVoiceToTextManagerDelegate, KBVoiceRecordManagerDelegate, UIGestureRecognizerDelegate, KBChatLimitPopViewDelegate, UITextViewDelegate>
///
@property (nonatomic, strong) UICollectionView *collectionView;
@@ -31,11 +31,24 @@
@property (nonatomic, assign) CGFloat voiceInputBarHeight;
@property (nonatomic, assign) CGFloat baseInputBarBottomSpacing;
@property (nonatomic, assign) CGFloat currentKeyboardHeight;
/// KBVoiceInputBar
/// "由 KBVoiceInputBar 触发的键盘"
@property (nonatomic, assign) BOOL voiceInputKeyboardActive;
@property (nonatomic, strong) UITapGestureRecognizer *dismissKeyboardTap;
@property (nonatomic, weak) LSTPopView *chatLimitPopView;
///
@property (nonatomic, strong) UIView *textInputContainerView;
///
@property (nonatomic, strong) UITextView *textInputTextView;
///
@property (nonatomic, strong) UIButton *sendButton;
///
@property (nonatomic, strong) UILabel *placeholderLabel;
///
@property (nonatomic, strong) MASConstraint *textInputContainerBottomConstraint;
///
@property (nonatomic, assign) BOOL isTextInputMode;
///
@property (nonatomic, strong) UIView *bottomBackgroundView;
@property (nonatomic, strong) UIVisualEffectView *bottomBlurEffectView;
@@ -94,12 +107,16 @@
return nil;
}
/// KBVoiceInputBar
/// KBVoiceInputBar
- (BOOL)kb_isKeyboardFromVoiceInputBar {
UIView *firstResponder = [self kb_findFirstResponderInView:self.view];
if (!firstResponder) {
return NO;
}
// textInputTextView
if (firstResponder == self.textInputTextView) {
return YES;
}
return [firstResponder isDescendantOfView:self.voiceInputBar];
}
@@ -117,9 +134,12 @@
self.currentIndex = 0;
self.preloadedIndexes = [NSMutableSet set];
self.aiVM = [[AiVM alloc] init];
self.isWaitingForAIResponse = NO; //
self.isWaitingForAIResponse = NO;
self.isTextInputMode = NO;
[self setupUI];
[self setupTextInputView];
[self setupVoiceInputBarCallback];
[self setupVoiceToTextManager];
[self setupVoiceRecordManager];
[self setupKeyboardNotifications];
@@ -151,13 +171,12 @@
make.right.equalTo(self.view).offset(-16);
make.width.height.mas_equalTo(32);
}];
//
[self.view addSubview:self.bottomBackgroundView];
[self.bottomBackgroundView addSubview:self.bottomBlurEffectView];
[self.bottomBackgroundView mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.right.equalTo(self.view);
// self.bottomBackgroundBottomConstraint = make.bottom.equalTo(self.view).offset(-self.baseInputBarBottomSpacing);
make.bottom.equalTo(self.view);
make.height.mas_equalTo(self.voiceInputBarHeight);
}];
@@ -170,10 +189,73 @@
[self.voiceInputBar mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.right.equalTo(self.view);
self.voiceInputBarBottomConstraint = make.bottom.equalTo(self.view).offset(-self.baseInputBarBottomSpacing);
make.height.mas_equalTo(self.voiceInputBarHeight); //
make.height.mas_equalTo(self.voiceInputBarHeight);
}];
}
///
- (void)setupTextInputView {
//
[self.view addSubview:self.textInputContainerView];
[self.textInputContainerView mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.right.equalTo(self.view);
self.textInputContainerBottomConstraint = make.bottom.equalTo(self.view).offset(100); //
make.height.mas_greaterThanOrEqualTo(50);
}];
[self.textInputContainerView addSubview:self.textInputTextView];
[self.textInputContainerView addSubview:self.sendButton];
[self.textInputTextView addSubview:self.placeholderLabel];
[self.sendButton mas_makeConstraints:^(MASConstraintMaker *make) {
make.right.equalTo(self.textInputContainerView).offset(-16);
make.bottom.equalTo(self.textInputContainerView).offset(-10);
make.width.mas_equalTo(60);
make.height.mas_equalTo(36);
}];
[self.textInputTextView mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(self.textInputContainerView).offset(16);
make.right.equalTo(self.sendButton.mas_left).offset(-10);
make.top.equalTo(self.textInputContainerView).offset(8);
make.bottom.equalTo(self.textInputContainerView).offset(-8);
make.height.mas_greaterThanOrEqualTo(36);
make.height.mas_lessThanOrEqualTo(100);
}];
[self.placeholderLabel mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(self.textInputTextView).offset(5);
make.top.equalTo(self.textInputTextView).offset(8);
}];
}
/// VoiceInputBar
- (void)setupVoiceInputBarCallback {
__weak typeof(self) weakSelf = self;
self.voiceInputBar.onTextSend = ^(NSString *text) {
//
[weakSelf showTextInputView];
};
}
///
- (void)showTextInputView {
self.isTextInputMode = YES;
self.voiceInputBar.hidden = YES;
self.textInputContainerView.hidden = NO;
[self.textInputTextView becomeFirstResponder];
}
///
- (void)hideTextInputView {
self.isTextInputMode = NO;
[self.textInputTextView resignFirstResponder];
self.textInputContainerView.hidden = YES;
self.voiceInputBar.hidden = NO;
self.textInputTextView.text = @"";
self.placeholderLabel.hidden = NO;
}
#pragma mark - 2
- (void)loadPersonas {
@@ -191,7 +273,6 @@
if (error) {
NSLog(@"加载人设列表失败:%@", error.localizedDescription);
// TODO:
return;
}
@@ -200,24 +281,18 @@
return;
}
//
[weakSelf.personas addObjectsFromArray:pageModel.records];
weakSelf.hasMore = pageModel.hasMore;
// UI
dispatch_async(dispatch_get_main_queue(), ^{
[weakSelf.collectionView reloadData];
// 3
if (weakSelf.currentPage == 1) {
[weakSelf preloadDataForIndexes:@[@0, @1, @2]];
}
});
NSLog(@"加载成功:当前 %ld 条,总共 %ld 条,还有更多:%@",
weakSelf.personas.count,
pageModel.total,
pageModel.hasMore ? @"是" : @"否");
weakSelf.personas.count, pageModel.total, pageModel.hasMore ? @"是" : @"否");
}];
}
@@ -225,7 +300,6 @@
if (!self.hasMore || self.isLoading) {
return;
}
self.currentPage++;
[self loadPersonas];
}
@@ -238,20 +312,13 @@
}
NSMutableArray *indexesToPreload = [NSMutableArray array];
//
if (index > 0) {
[indexesToPreload addObject:@(index - 1)];
}
//
[indexesToPreload addObject:@(index)];
//
if (index < self.personas.count - 1) {
[indexesToPreload addObject:@(index + 1)];
}
[self preloadDataForIndexes:indexesToPreload];
}
@@ -260,7 +327,6 @@
if ([self.preloadedIndexes containsObject:indexNum]) {
continue;
}
[self.preloadedIndexes addObject:indexNum];
NSInteger index = [indexNum integerValue];
@@ -270,11 +336,9 @@
NSIndexPath *indexPath = [NSIndexPath indexPathForItem:index inSection:0];
KBPersonaChatCell *cell = (KBPersonaChatCell *)[self.collectionView cellForItemAtIndexPath:indexPath];
if (cell) {
[cell preloadDataIfNeeded];
}
NSLog(@"预加载第 %ld 个人设", (long)index);
}
}
@@ -289,20 +353,14 @@
KBPersonaChatCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"KBPersonaChatCell" forIndexPath:indexPath];
cell.persona = self.personas[indexPath.item];
[self updateChatViewBottomInset];
//
[self.preloadedIndexes addObject:@(indexPath.item)];
//
[cell preloadDataIfNeeded];
return cell;
}
#pragma mark - UIScrollViewDelegate
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
// AI
if (self.isWaitingForAIResponse) {
return;
}
@@ -311,14 +369,12 @@
CGFloat offsetY = scrollView.contentOffset.y;
NSInteger currentPage = offsetY / pageHeight;
// 30%
if (fmod(offsetY, pageHeight) > pageHeight * 0.3) {
[self preloadAdjacentCellsForIndex:currentPage + 1];
} else {
[self preloadAdjacentCellsForIndex:currentPage];
}
//
if (offsetY + scrollView.bounds.size.height >= scrollView.contentSize.height - pageHeight) {
[self loadMorePersonas];
}
@@ -332,15 +388,12 @@
if (currentPage < self.personas.count) {
NSLog(@"当前在第 %ld 个人设:%@", (long)currentPage, self.personas[currentPage].name);
}
[self updateChatViewBottomInset];
}
/// AI
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView {
if (self.isWaitingForAIResponse) {
NSLog(@"[KBAIHomeVC] 正在等待 AI 回复,禁止滚动");
//
scrollView.scrollEnabled = NO;
scrollView.scrollEnabled = YES;
}
@@ -377,45 +430,42 @@
NSTimeInterval duration = [userInfo[UIKeyboardAnimationDurationUserInfoKey] doubleValue];
UIViewAnimationOptions options = ([userInfo[UIKeyboardAnimationCurveUserInfoKey] integerValue] << 16);
// frame view
CGRect convertedFrame = [self.view convertRect:endFrame fromView:nil];
CGFloat keyboardHeight = MAX(0.0, CGRectGetMaxY(self.view.bounds) - CGRectGetMinY(convertedFrame));
// Gate KBVoiceInputBar
if (keyboardHeight > 0.0) {
if (![self kb_isKeyboardFromVoiceInputBar]) {
return;
}
self.voiceInputKeyboardActive = YES;
} else {
// KBVoiceInputBar
if (!self.voiceInputKeyboardActive) {
return;
}
self.voiceInputKeyboardActive = NO;
// VoiceInputBar
if (self.isTextInputMode) {
[self hideTextInputView];
}
}
self.currentKeyboardHeight = keyboardHeight;
NSLog(@"[KBAIHomeVC] 键盘高度: %.2f, 屏幕高度: %.2f, 键盘 Y: %.2f",
keyboardHeight, CGRectGetMaxY(self.view.bounds), CGRectGetMinY(convertedFrame));
// 1 VoiceInputBar view.bottom
NSLog(@"[KBAIHomeVC] 键盘高度: %.2f", keyboardHeight);
CGFloat bottomSpacing;
if (keyboardHeight > 0.0) {
// VoiceInputBar 5px
// bottomSpacing = - 5px offset
bottomSpacing = keyboardHeight - 5.0;
//
if (self.isTextInputMode) {
[self.textInputContainerBottomConstraint setOffset:-keyboardHeight];
}
} else {
//
bottomSpacing = self.baseInputBarBottomSpacing;
[self.textInputContainerBottomConstraint setOffset:100]; //
}
NSLog(@"[KBAIHomeVC] VoiceInputBar bottomSpacing: %.2f", bottomSpacing);
[self.voiceInputBarBottomConstraint setOffset:-bottomSpacing];
// 2 ChatView bottomInset
[self updateChatViewBottomInset];
[UIView animateWithDuration:duration
@@ -447,6 +497,9 @@
if ([touch.view isDescendantOfView:self.voiceInputBar]) {
return NO;
}
if ([touch.view isDescendantOfView:self.textInputContainerView]) {
return NO;
}
return YES;
}
@@ -486,46 +539,27 @@
return visibleCell;
}
}
return nil;
}
#pragma mark - Private
- (void)updateChatViewBottomInset {
// bottomInset VoiceInputBar
CGFloat bottomInset;
if (self.currentKeyboardHeight > 0.0) {
//
// avatarImageView = KB_TABBAR_HEIGHT + 50 + 20
// chatView = KB_TABBAR_HEIGHT + 50 + 20 + 54() + 10() 153
// VoiceInputBar chatView
// bottomInset = + VoiceInputBar - chatView
CGFloat avatarBottomSpace = KB_TABBAR_HEIGHT + 50 + 20; // avatarImageView
CGFloat chatViewPhysicalBottomSpace = avatarBottomSpace + 54 + 10; // chatView
// = ( + InputBar) - chatView
CGFloat avatarBottomSpace = KB_TABBAR_HEIGHT + 50 + 20;
CGFloat chatViewPhysicalBottomSpace = avatarBottomSpace + 54 + 10;
bottomInset = (self.currentKeyboardHeight + self.voiceInputBarHeight) - chatViewPhysicalBottomSpace;
//
bottomInset = MAX(bottomInset, 0);
NSLog(@"[KBAIHomeVC] 键盘弹起 - bottomInset: %.2f (键盘: %.2f + InputBar: %.2f - 已避开: %.2f)",
bottomInset, self.currentKeyboardHeight, self.voiceInputBarHeight, chatViewPhysicalBottomSpace);
} else {
// bottomInset
bottomInset = 0;
NSLog(@"[KBAIHomeVC] 键盘隐藏 - bottomInset: %.2f", bottomInset);
}
for (NSIndexPath *indexPath in self.collectionView.indexPathsForVisibleItems) {
KBPersonaChatCell *cell = (KBPersonaChatCell *)[self.collectionView cellForItemAtIndexPath:indexPath];
if (cell) {
[cell updateChatViewBottomInset:bottomInset];
//
if (self.currentKeyboardHeight > 0.0) {
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[cell.chatView scrollToBottom];
@@ -557,6 +591,18 @@
[pop pop];
}
#pragma mark - UITextViewDelegate
- (void)textViewDidChange:(UITextView *)textView {
self.placeholderLabel.hidden = textView.text.length > 0;
//
CGSize size = [textView sizeThatFits:CGSizeMake(textView.frame.size.width, CGFLOAT_MAX)];
CGFloat newHeight = MIN(MAX(size.height, 36), 100);
[textView mas_updateConstraints:^(MASConstraintMaker *make) {
make.height.mas_greaterThanOrEqualTo(newHeight);
}];
}
#pragma mark - Lazy Load
- (UICollectionView *)collectionView {
@@ -590,6 +636,55 @@
return _voiceInputBar;
}
- (UIView *)textInputContainerView {
if (!_textInputContainerView) {
_textInputContainerView = [[UIView alloc] init];
_textInputContainerView.backgroundColor = [UIColor whiteColor];
_textInputContainerView.hidden = YES;
}
return _textInputContainerView;
}
- (UITextView *)textInputTextView {
if (!_textInputTextView) {
_textInputTextView = [[UITextView alloc] init];
_textInputTextView.font = [UIFont systemFontOfSize:16];
_textInputTextView.textColor = [UIColor blackColor];
_textInputTextView.backgroundColor = [UIColor colorWithRed:0.95 green:0.95 blue:0.95 alpha:1.0];
_textInputTextView.layer.cornerRadius = 18;
_textInputTextView.layer.masksToBounds = YES;
_textInputTextView.textContainerInset = UIEdgeInsetsMake(8, 8, 8, 8);
_textInputTextView.delegate = self;
_textInputTextView.returnKeyType = UIReturnKeySend;
_textInputTextView.enablesReturnKeyAutomatically = YES;
}
return _textInputTextView;
}
- (UIButton *)sendButton {
if (!_sendButton) {
_sendButton = [UIButton buttonWithType:UIButtonTypeCustom];
[_sendButton setTitle:KBLocalized(@"发送") forState:UIControlStateNormal];
[_sendButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
_sendButton.titleLabel.font = [UIFont systemFontOfSize:15 weight:UIFontWeightMedium];
_sendButton.backgroundColor = [UIColor colorWithRed:0.2 green:0.6 blue:1.0 alpha:1.0];
_sendButton.layer.cornerRadius = 18;
_sendButton.layer.masksToBounds = YES;
[_sendButton addTarget:self action:@selector(sendButtonTapped) forControlEvents:UIControlEventTouchUpInside];
}
return _sendButton;
}
- (UILabel *)placeholderLabel {
if (!_placeholderLabel) {
_placeholderLabel = [[UILabel alloc] init];
_placeholderLabel.text = KBLocalized(@"输入消息...");
_placeholderLabel.font = [UIFont systemFontOfSize:16];
_placeholderLabel.textColor = [UIColor lightGrayColor];
}
return _placeholderLabel;
}
#pragma mark - KBChatLimitPopViewDelegate
- (void)chatLimitPopViewDidTapCancel:(KBChatLimitPopView *)view {
@@ -654,6 +749,24 @@
[self.navigationController pushViewController:vc animated:YES];
}
/// - handleTranscribedText
- (void)sendButtonTapped {
NSString *text = [self.textInputTextView.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
if (text.length == 0) {
return;
}
//
self.textInputTextView.text = @"";
self.placeholderLabel.hidden = NO;
//
[self hideTextInputView];
// handleTranscribedText
[self handleTranscribedText:text];
}
#pragma mark - KBVoiceToTextManagerDelegate
- (void)voiceToTextManager:(KBVoiceToTextManager *)manager
@@ -689,7 +802,6 @@
unsigned long long fileSize = [attributes[NSFileSize] unsignedLongLongValue];
NSLog(@"[KBAIHomeVC] 录音完成,时长: %.2fs,大小: %llu bytes", duration, fileSize);
// loading
KBPersonaChatCell *currentCell = [self currentPersonaCell];
if (currentCell) {
[currentCell appendLoadingUserMessage];
@@ -725,12 +837,10 @@
return;
}
// loading
if (cell) {
[cell updateLastUserMessage:transcript];
}
//
[strongSelf handleTranscribedText:transcript appendToUI:NO];
});
}];
@@ -756,7 +866,7 @@
if (text.length == 0) {
return;
}
NSLog(@"[KBAIHomeVC] 语音识别结果%@", text);
NSLog(@"[KBAIHomeVC] 发送消息%@", text);
NSInteger companionId = [self currentCompanionId];
if (companionId <= 0) {
@@ -769,7 +879,6 @@
[currentCell appendUserMessage:text];
}
// CollectionView
self.isWaitingForAIResponse = YES;
self.collectionView.scrollEnabled = NO;
NSLog(@"[KBAIHomeVC] 开始等待 AI 回复,禁止 CollectionView 滚动");
@@ -784,12 +893,10 @@
}
dispatch_async(dispatch_get_main_queue(), ^{
// CollectionView
strongSelf.isWaitingForAIResponse = NO;
strongSelf.collectionView.scrollEnabled = YES;
NSLog(@"[KBAIHomeVC] AI 回复完成,恢复 CollectionView 滚动");
// loading
KBPersonaChatCell *cell = [strongSelf currentPersonaCell];
if (cell) {
[cell markLastUserMessageLoadingComplete];