382 lines
16 KiB
Objective-C
382 lines
16 KiB
Objective-C
//
|
||
// KBChatAssistantMessageCell.m
|
||
// keyBoard
|
||
//
|
||
// Created by Kiro on 2026/1/23.
|
||
//
|
||
|
||
#import "KBChatAssistantMessageCell.h"
|
||
#import "KBAiChatMessage.h"
|
||
#import <Masonry/Masonry.h>
|
||
|
||
@interface KBChatAssistantMessageCell ()
|
||
|
||
@property (nonatomic, strong) UIButton *voiceButton;
|
||
@property (nonatomic, strong) UILabel *durationLabel;
|
||
@property (nonatomic, strong) UIView *bubbleView;
|
||
@property (nonatomic, strong, readwrite) UILabel *messageLabel; // readwrite 允许内部修改
|
||
@property (nonatomic, strong) UIActivityIndicatorView *loadingIndicator;
|
||
@property (nonatomic, strong) UIActivityIndicatorView *messageLoadingIndicator; // AI 消息加载指示器
|
||
@property (nonatomic, strong) KBAiChatMessage *currentMessage;
|
||
|
||
// 打字机效果
|
||
@property (nonatomic, strong) NSTimer *typewriterTimer;
|
||
@property (nonatomic, copy) NSString *fullText;
|
||
@property (nonatomic, assign) NSInteger currentCharIndex;
|
||
|
||
@end
|
||
|
||
@implementation KBChatAssistantMessageCell
|
||
|
||
- (instancetype)initWithStyle:(UITableViewCellStyle)style
|
||
reuseIdentifier:(NSString *)reuseIdentifier {
|
||
self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
|
||
if (self) {
|
||
self.backgroundColor = [UIColor clearColor];
|
||
self.selectionStyle = UITableViewCellSelectionStyleNone;
|
||
[self setupUI];
|
||
}
|
||
return self;
|
||
}
|
||
|
||
- (void)setupUI {
|
||
// 语音按钮
|
||
self.voiceButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
||
[self.voiceButton setImage:[UIImage systemImageNamed:@"play.circle.fill"]
|
||
forState:UIControlStateNormal];
|
||
self.voiceButton.tintColor = [UIColor whiteColor];
|
||
[self.voiceButton addTarget:self
|
||
action:@selector(voiceButtonTapped)
|
||
forControlEvents:UIControlEventTouchUpInside];
|
||
[self.contentView addSubview:self.voiceButton];
|
||
|
||
// 语音时长标签
|
||
self.durationLabel = [[UILabel alloc] init];
|
||
self.durationLabel.font = [UIFont systemFontOfSize:14];
|
||
self.durationLabel.textColor = [UIColor whiteColor];
|
||
[self.contentView addSubview:self.durationLabel];
|
||
|
||
// 加载指示器
|
||
self.loadingIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
|
||
self.loadingIndicator.color = [UIColor whiteColor];
|
||
self.loadingIndicator.hidesWhenStopped = YES;
|
||
[self.contentView addSubview:self.loadingIndicator];
|
||
|
||
// AI 消息加载指示器(用于等待 AI 回复)
|
||
self.messageLoadingIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
|
||
self.messageLoadingIndicator.color = [UIColor whiteColor];
|
||
self.messageLoadingIndicator.hidesWhenStopped = YES;
|
||
[self.contentView addSubview:self.messageLoadingIndicator];
|
||
|
||
// 气泡视图
|
||
self.bubbleView = [[UIView alloc] init];
|
||
self.bubbleView.backgroundColor = [UIColor colorWithRed:0.2 green:0.2 blue:0.2 alpha:0.7];
|
||
self.bubbleView.layer.cornerRadius = 16;
|
||
self.bubbleView.layer.masksToBounds = YES;
|
||
[self.contentView addSubview:self.bubbleView];
|
||
|
||
// 消息标签
|
||
self.messageLabel = [[UILabel alloc] init];
|
||
self.messageLabel.numberOfLines = 0;
|
||
self.messageLabel.font = [UIFont systemFontOfSize:16];
|
||
self.messageLabel.textColor = [UIColor whiteColor];
|
||
self.messageLabel.lineBreakMode = NSLineBreakByWordWrapping;
|
||
// 设置 preferredMaxLayoutWidth 让 AutoLayout 能正确计算多行高度
|
||
CGFloat maxWidth = [UIScreen mainScreen].bounds.size.width * 0.75 - 16 - 24;
|
||
self.messageLabel.preferredMaxLayoutWidth = maxWidth;
|
||
[self.bubbleView addSubview:self.messageLabel];
|
||
|
||
// 布局约束
|
||
[self.voiceButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||
make.left.equalTo(self.contentView).offset(16);
|
||
make.top.equalTo(self.contentView).offset(8);
|
||
make.width.height.mas_equalTo(24);
|
||
}];
|
||
|
||
[self.durationLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||
make.left.equalTo(self.voiceButton.mas_right).offset(4);
|
||
make.centerY.equalTo(self.voiceButton);
|
||
}];
|
||
|
||
[self.loadingIndicator mas_makeConstraints:^(MASConstraintMaker *make) {
|
||
make.center.equalTo(self.voiceButton);
|
||
}];
|
||
|
||
// AI 消息加载指示器约束(与 bubbleView 位置一致)
|
||
[self.messageLoadingIndicator mas_makeConstraints:^(MASConstraintMaker *make) {
|
||
make.left.equalTo(self.contentView).offset(16);
|
||
make.top.equalTo(self.voiceButton.mas_bottom).offset(12);
|
||
}];
|
||
|
||
// 关键修复:bubbleView 必须有明确的高度约束链
|
||
[self.bubbleView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||
make.top.equalTo(self.voiceButton.mas_bottom).offset(4);
|
||
make.bottom.equalTo(self.contentView).offset(-4).priority(999); // 降低优先级避免冲突
|
||
make.left.equalTo(self.contentView).offset(16);
|
||
make.width.lessThanOrEqualTo(self.contentView).multipliedBy(0.75);
|
||
}];
|
||
|
||
// 关键修复:messageLabel 约束必须完整,让 AutoLayout 能推导出 bubbleView 的高度
|
||
[self.messageLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||
make.top.equalTo(self.bubbleView).offset(5);
|
||
make.bottom.equalTo(self.bubbleView).offset(-5).priority(999); // 降低优先级
|
||
make.left.equalTo(self.bubbleView).offset(12);
|
||
make.right.equalTo(self.bubbleView).offset(-12);
|
||
// 关键修复:给 messageLabel 一个最小高度,防止高度为 0
|
||
make.height.greaterThanOrEqualTo(@20);
|
||
}];
|
||
}
|
||
|
||
- (void)configureWithMessage:(KBAiChatMessage *)message {
|
||
NSLog(@"[KBChatAssistantMessageCell] 配置消息 - 文本长度: %lu, isComplete: %d, needsTypewriter: %d, isLoading: %d, 打字机运行中: %d",
|
||
(unsigned long)message.text.length, message.isComplete, message.needsTypewriterEffect, message.isLoading,
|
||
(self.typewriterTimer && self.typewriterTimer.isValid));
|
||
|
||
// 先停止之前的打字机效果(无论是否是同一条消息)
|
||
[self stopTypewriterEffect];
|
||
|
||
self.currentMessage = message;
|
||
|
||
// 处理 loading 状态
|
||
if (message.isLoading) {
|
||
self.messageLabel.attributedText = nil;
|
||
self.messageLabel.text = @"";
|
||
self.bubbleView.hidden = YES;
|
||
self.voiceButton.hidden = YES;
|
||
self.durationLabel.hidden = YES;
|
||
[self.messageLoadingIndicator startAnimating];
|
||
return;
|
||
}
|
||
|
||
// 非 loading 状态,隐藏 loading 指示器,显示气泡
|
||
[self.messageLoadingIndicator stopAnimating];
|
||
self.bubbleView.hidden = NO;
|
||
|
||
// 只有明确需要打字机效果的消息才使用打字机
|
||
if (message.needsTypewriterEffect && !message.isComplete && message.text.length > 0) {
|
||
// 启动新的打字机效果
|
||
NSLog(@"[KBChatAssistantMessageCell] 启动新的打字机效果");
|
||
[self startTypewriterEffectWithText:message.text];
|
||
} else {
|
||
// 关键修复:直接显示完整文本时,清除 attributedText,使用普通 text
|
||
NSLog(@"[KBChatAssistantMessageCell] 直接显示完整文本(needsTypewriter: %d, isComplete: %d)",
|
||
message.needsTypewriterEffect, message.isComplete);
|
||
self.messageLabel.attributedText = nil; // 清除 attributedText
|
||
self.messageLabel.text = message.text; // 设置普通文本
|
||
|
||
// 强制布局更新
|
||
[self.contentView setNeedsLayout];
|
||
[self.contentView layoutIfNeeded];
|
||
|
||
NSLog(@"[KBChatAssistantMessageCell] 直接显示后 Label frame: %@",
|
||
NSStringFromCGRect(self.messageLabel.frame));
|
||
}
|
||
|
||
// 格式化语音时长(如果时长为 0,不显示)
|
||
if (message.audioDuration > 0) {
|
||
NSInteger seconds = (NSInteger)ceil(message.audioDuration);
|
||
self.durationLabel.text = [NSString stringWithFormat:@"%ld\"", (long)seconds];
|
||
} else {
|
||
self.durationLabel.text = @"";
|
||
}
|
||
|
||
// 如果有 audioId 或 audioData,显示语音按钮
|
||
BOOL hasAudio = (message.audioId.length > 0) ||
|
||
(message.audioData != nil && message.audioData.length > 0);
|
||
self.voiceButton.hidden = !hasAudio;
|
||
self.durationLabel.hidden = !hasAudio;
|
||
}
|
||
|
||
- (void)updateVoicePlayingState:(BOOL)isPlaying {
|
||
if (isPlaying) {
|
||
[self.voiceButton setImage:[UIImage systemImageNamed:@"pause.circle.fill"]
|
||
forState:UIControlStateNormal];
|
||
} else {
|
||
[self.voiceButton setImage:[UIImage systemImageNamed:@"play.circle.fill"]
|
||
forState:UIControlStateNormal];
|
||
}
|
||
}
|
||
|
||
- (void)showLoadingAnimation {
|
||
// 隐藏按钮图标,显示加载动画
|
||
[self.voiceButton setImage:nil forState:UIControlStateNormal];
|
||
[self.loadingIndicator startAnimating];
|
||
}
|
||
|
||
- (void)hideLoadingAnimation {
|
||
// 停止加载动画,恢复按钮图标
|
||
[self.loadingIndicator stopAnimating];
|
||
[self.voiceButton setImage:[UIImage systemImageNamed:@"play.circle.fill"]
|
||
forState:UIControlStateNormal];
|
||
}
|
||
|
||
- (void)voiceButtonTapped {
|
||
if ([self.delegate respondsToSelector:@selector(assistantMessageCell:didTapVoiceButtonForMessage:)]) {
|
||
[self.delegate assistantMessageCell:self didTapVoiceButtonForMessage:self.currentMessage];
|
||
}
|
||
}
|
||
|
||
#pragma mark - Typewriter Effect
|
||
|
||
- (void)startTypewriterEffectWithText:(NSString *)text {
|
||
if (text.length == 0) {
|
||
NSLog(@"[KBChatAssistantMessageCell] 文本为空,跳过打字机效果");
|
||
return;
|
||
}
|
||
|
||
NSLog(@"[KBChatAssistantMessageCell] 开始打字机效果,文本长度: %lu, 文本: %@",
|
||
(unsigned long)text.length, text);
|
||
|
||
self.fullText = text;
|
||
self.currentCharIndex = 0;
|
||
|
||
// 关键修复:先设置普通文本,让布局系统计算出正确的高度
|
||
self.messageLabel.text = text;
|
||
|
||
// 强制布局更新,确保 Cell 有正确的高度
|
||
[self.contentView setNeedsLayout];
|
||
[self.contentView layoutIfNeeded];
|
||
|
||
NSLog(@"[KBChatAssistantMessageCell] 布局后 Label frame: %@, bubbleView frame: %@",
|
||
NSStringFromCGRect(self.messageLabel.frame),
|
||
NSStringFromCGRect(self.bubbleView.frame));
|
||
|
||
// 关键修复:布局完成后再应用打字机效果的 attributedText
|
||
dispatch_async(dispatch_get_main_queue(), ^{
|
||
// 使用 NSAttributedString 实现打字机效果
|
||
// 先把所有文字设置为透明,然后逐个显示
|
||
NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text];
|
||
[attributedText addAttribute:NSForegroundColorAttributeName
|
||
value:[UIColor clearColor]
|
||
range:NSMakeRange(0, text.length)];
|
||
[attributedText addAttribute:NSFontAttributeName
|
||
value:self.messageLabel.font
|
||
range:NSMakeRange(0, text.length)];
|
||
self.messageLabel.attributedText = attributedText;
|
||
|
||
// 再次强制布局,确保 attributedText 不会改变高度
|
||
[self.contentView layoutIfNeeded];
|
||
|
||
// 每 0.03 秒显示一个字符(更快的打字机效果)
|
||
self.typewriterTimer = [NSTimer scheduledTimerWithTimeInterval:0.03
|
||
target:self
|
||
selector:@selector(typewriterTick)
|
||
userInfo:nil
|
||
repeats:YES];
|
||
|
||
// 将定时器添加到 RunLoop 的 common 模式,确保滚动时也能触发
|
||
[[NSRunLoop currentRunLoop] addTimer:self.typewriterTimer forMode:NSRunLoopCommonModes];
|
||
|
||
// 立即触发一次,显示第一个字符
|
||
[self typewriterTick];
|
||
|
||
NSLog(@"[KBChatAssistantMessageCell] 定时器已创建: %@", self.typewriterTimer);
|
||
});
|
||
}
|
||
|
||
- (void)typewriterTick {
|
||
// 使用局部变量保存 fullText,避免多线程问题
|
||
NSString *text = self.fullText;
|
||
|
||
// 检查 fullText 是否有效
|
||
if (!text || text.length == 0) {
|
||
NSLog(@"[KBChatAssistantMessageCell] fullText 无效,停止打字机");
|
||
[self stopTypewriterEffect];
|
||
return;
|
||
}
|
||
|
||
// 如果当前索引小于完整文本长度,继续显示
|
||
if (self.currentCharIndex < text.length) {
|
||
self.currentCharIndex++;
|
||
|
||
// 使用 NSAttributedString 显示前 N 个字符,隐藏后面的字符
|
||
NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text];
|
||
|
||
// 已显示的部分:白色
|
||
if (self.currentCharIndex > 0) {
|
||
[attributedText addAttribute:NSForegroundColorAttributeName
|
||
value:[UIColor whiteColor]
|
||
range:NSMakeRange(0, self.currentCharIndex)];
|
||
}
|
||
|
||
// 未显示的部分:透明
|
||
if (self.currentCharIndex < text.length) {
|
||
[attributedText addAttribute:NSForegroundColorAttributeName
|
||
value:[UIColor clearColor]
|
||
range:NSMakeRange(self.currentCharIndex, text.length - self.currentCharIndex)];
|
||
}
|
||
|
||
// 设置字体
|
||
[attributedText addAttribute:NSFontAttributeName
|
||
value:self.messageLabel.font
|
||
range:NSMakeRange(0, text.length)];
|
||
|
||
self.messageLabel.attributedText = attributedText;
|
||
|
||
// 每 10 个字符打印一次日志
|
||
if (self.currentCharIndex % 10 == 0) {
|
||
NSString *displayText = [text substringToIndex:self.currentCharIndex];
|
||
NSLog(@"[KBChatAssistantMessageCell] 打字机进度: %ld/%lu, 当前文本: %@",
|
||
(long)self.currentCharIndex, (unsigned long)text.length, displayText);
|
||
NSLog(@"[KBChatAssistantMessageCell] Label 状态 - frame: %@, hidden: %d, alpha: %.2f",
|
||
NSStringFromCGRect(self.messageLabel.frame),
|
||
self.messageLabel.hidden,
|
||
self.messageLabel.alpha);
|
||
}
|
||
} else {
|
||
// 打字机效果已完成,停止定时器并标记消息完成
|
||
NSLog(@"[KBChatAssistantMessageCell] 打字机效果完成,停止定时器");
|
||
|
||
// 检查 text 是否为空
|
||
if (!text || text.length == 0) {
|
||
NSLog(@"[KBChatAssistantMessageCell] text 为空,跳过");
|
||
[self stopTypewriterEffect];
|
||
return;
|
||
}
|
||
|
||
[self stopTypewriterEffect];
|
||
|
||
// 显示完整文本(白色),使用 attributedText 保持布局
|
||
NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text];
|
||
[attributedText addAttribute:NSForegroundColorAttributeName
|
||
value:[UIColor whiteColor]
|
||
range:NSMakeRange(0, text.length)];
|
||
[attributedText addAttribute:NSFontAttributeName
|
||
value:self.messageLabel.font
|
||
range:NSMakeRange(0, text.length)];
|
||
self.messageLabel.attributedText = attributedText;
|
||
|
||
// 标记消息为完成状态,避免 Cell 复用时重新触发打字机
|
||
if (self.currentMessage) {
|
||
self.currentMessage.isComplete = YES;
|
||
self.currentMessage.needsTypewriterEffect = NO;
|
||
NSLog(@"[KBChatAssistantMessageCell] 标记消息完成 - 文本: %@", self.currentMessage.text);
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
- (void)stopTypewriterEffect {
|
||
if (self.typewriterTimer && self.typewriterTimer.isValid) {
|
||
NSLog(@"[KBChatAssistantMessageCell] 停止打字机定时器");
|
||
[self.typewriterTimer invalidate];
|
||
}
|
||
self.typewriterTimer = nil;
|
||
self.currentCharIndex = 0;
|
||
self.fullText = nil;
|
||
}
|
||
|
||
- (void)prepareForReuse {
|
||
[super prepareForReuse];
|
||
[self stopTypewriterEffect];
|
||
// 清空文本,避免复用时显示旧内容
|
||
self.messageLabel.text = @"";
|
||
self.messageLabel.attributedText = nil;
|
||
}
|
||
|
||
- (void)dealloc {
|
||
[self stopTypewriterEffect];
|
||
}
|
||
|
||
@end
|