Files
keyboard/keyBoard/Class/AiTalk/V/KBChatAssistantMessageCell.m
2026-01-28 20:18:18 +08:00

355 lines
15 KiB
Objective-C
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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) 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];
// 气泡视图
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);
make.width.height.mas_equalTo(0);
}];
[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);
}];
// 关键修复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, 打字机运行中: %d",
(unsigned long)message.text.length, message.isComplete, message.needsTypewriterEffect,
(self.typewriterTimer && self.typewriterTimer.isValid));
// 先停止之前的打字机效果(无论是否是同一条消息)
[self stopTypewriterEffect];
self.currentMessage = message;
// 只有明确需要打字机效果的消息才使用打字机
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