// // KBChatMessageCell.m // CustomKeyboard // #import "KBChatMessageCell.h" #import "KBChatMessage.h" #import "Masonry.h" @interface KBChatMessageCell () @property (nonatomic, strong) UIImageView *avatarView; @property (nonatomic, strong) UILabel *nameLabel; @property (nonatomic, strong) UIView *bubbleView; @property (nonatomic, strong) UILabel *messageLabel; @property (nonatomic, strong) UIImageView *audioIconView; @property (nonatomic, strong) UILabel *audioLabel; /// 语音播放按钮 @property (nonatomic, strong) UIButton *voiceButton; /// 语音时长标签 @property (nonatomic, strong) UILabel *durationLabel; /// 语音加载指示器 @property (nonatomic, strong) UIActivityIndicatorView *voiceLoadingIndicator; /// 消息加载指示器(AI 回复 loading) @property (nonatomic, strong) UIActivityIndicatorView *messageLoadingIndicator; /// 当前消息 @property (nonatomic, strong) KBChatMessage *currentMessage; /// 打字机效果 @property (nonatomic, strong) NSTimer *typewriterTimer; @property (nonatomic, copy) NSString *fullText; @property (nonatomic, assign) NSInteger currentCharIndex; @end @implementation KBChatMessageCell - (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier { if (self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]) { self.backgroundColor = [UIColor clearColor]; self.contentView.backgroundColor = [UIColor clearColor]; self.selectionStyle = UITableViewCellSelectionStyleNone; [self.contentView addSubview:self.avatarView]; [self.contentView addSubview:self.nameLabel]; [self.contentView addSubview:self.voiceButton]; [self.contentView addSubview:self.durationLabel]; [self.contentView addSubview:self.voiceLoadingIndicator]; [self.contentView addSubview:self.messageLoadingIndicator]; [self.contentView addSubview:self.bubbleView]; [self.bubbleView addSubview:self.messageLabel]; [self.bubbleView addSubview:self.audioIconView]; [self.bubbleView addSubview:self.audioLabel]; } return self; } - (void)kb_configureWithMessage:(KBChatMessage *)message { // 先停止之前的打字机效果 [self kb_stopTypewriterEffect]; self.currentMessage = message; BOOL outgoing = message.outgoing; BOOL audioMessage = (!outgoing && message.audioFilePath.length > 0); UIColor *bubbleColor = outgoing ? [UIColor colorWithHex:0x02BEAC] : [UIColor colorWithWhite:1 alpha:0.95]; UIColor *incomingTextColor = [UIColor kb_dynamicColorWithLightColor:[UIColor colorWithHex:0x1B1F1A] darkColor:[UIColor whiteColor]]; UIColor *textColor = outgoing ? [UIColor whiteColor] : incomingTextColor; UIColor *nameColor = [UIColor kb_dynamicColorWithLightColor:[UIColor colorWithHex:0x6B6F7A] darkColor:[UIColor colorWithHex:0xC7CBD4]]; self.bubbleView.backgroundColor = bubbleColor; self.messageLabel.textColor = textColor; self.audioLabel.textColor = textColor; self.audioIconView.tintColor = textColor; self.audioLabel.text = (message.text.length > 0) ? message.text : KBLocalized(@"语音回复"); self.messageLabel.hidden = audioMessage; self.audioIconView.hidden = !audioMessage; self.audioLabel.hidden = !audioMessage; UIImage *avatarImage = message.avatarImage; if (!avatarImage) { avatarImage = [self kb_defaultAvatarImage]; } self.avatarView.image = avatarImage; self.avatarView.backgroundColor = avatarImage ? [UIColor clearColor] : [UIColor colorWithWhite:0.9 alpha:1.0]; self.nameLabel.hidden = outgoing; self.nameLabel.textColor = nameColor; self.nameLabel.text = (message.displayName.length > 0) ? message.displayName : KBLocalized(@"AI助手"); // 处理 loading 状态 if (message.isLoading && !outgoing) { self.bubbleView.hidden = YES; self.voiceButton.hidden = YES; self.durationLabel.hidden = YES; [self.messageLoadingIndicator startAnimating]; [self kb_layoutForOutgoing:outgoing audioMessage:NO]; return; } // 非 loading 状态 [self.messageLoadingIndicator stopAnimating]; self.bubbleView.hidden = NO; // 语音按钮显示逻辑(仅 AI 消息且有 audioId 或 audioData) BOOL hasAudio = (!outgoing) && (message.audioId.length > 0 || message.audioData.length > 0); self.voiceButton.hidden = !hasAudio; self.durationLabel.hidden = !hasAudio; if (hasAudio && message.audioDuration > 0) { NSInteger seconds = (NSInteger)ceil(message.audioDuration); self.durationLabel.text = [NSString stringWithFormat:@"%ld\"", (long)seconds]; } else { self.durationLabel.text = @""; } // 打字机效果 if (!outgoing && message.needsTypewriterEffect && !message.isComplete && message.text.length > 0) { [self kb_startTypewriterEffectWithText:message.text]; } else { self.messageLabel.attributedText = nil; self.messageLabel.text = message.text ?: @""; } [self kb_layoutForOutgoing:outgoing audioMessage:audioMessage]; } - (void)kb_layoutForOutgoing:(BOOL)outgoing audioMessage:(BOOL)audioMessage { CGFloat avatarSize = 28.0; [self.avatarView mas_remakeConstraints:^(MASConstraintMaker *make) { make.width.height.mas_equalTo(avatarSize); make.top.equalTo(self.contentView.mas_top).offset(6); if (outgoing) { make.right.equalTo(self.contentView.mas_right).offset(-8); } else { make.left.equalTo(self.contentView.mas_left).offset(8); } }]; if (outgoing) { [self.nameLabel mas_remakeConstraints:^(MASConstraintMaker *make) { make.top.equalTo(self.contentView.mas_top).offset(0); make.left.equalTo(self.contentView.mas_left); }]; // 用户消息不显示语音按钮 [self.voiceButton mas_remakeConstraints:^(MASConstraintMaker *make) { make.width.height.mas_equalTo(0); make.left.top.equalTo(self.contentView); }]; [self.durationLabel mas_remakeConstraints:^(MASConstraintMaker *make) { make.width.height.mas_equalTo(0); make.left.top.equalTo(self.contentView); }]; } else { [self.nameLabel mas_remakeConstraints:^(MASConstraintMaker *make) { make.left.equalTo(self.avatarView.mas_right).offset(6); make.top.equalTo(self.contentView.mas_top).offset(2); make.right.lessThanOrEqualTo(self.contentView.mas_right).offset(-12); }]; // AI 消息语音按钮 [self.voiceButton mas_remakeConstraints:^(MASConstraintMaker *make) { make.left.equalTo(self.avatarView.mas_right).offset(6); make.top.equalTo(self.nameLabel.mas_bottom).offset(4); make.width.height.mas_equalTo(20); }]; [self.durationLabel mas_remakeConstraints:^(MASConstraintMaker *make) { make.left.equalTo(self.voiceButton.mas_right).offset(4); make.centerY.equalTo(self.voiceButton); }]; [self.voiceLoadingIndicator mas_remakeConstraints:^(MASConstraintMaker *make) { make.center.equalTo(self.voiceButton); }]; } // 消息加载指示器 [self.messageLoadingIndicator mas_remakeConstraints:^(MASConstraintMaker *make) { if (outgoing) { make.right.equalTo(self.avatarView.mas_left).offset(-10); } else { make.left.equalTo(self.avatarView.mas_right).offset(10); } make.top.equalTo(self.nameLabel.mas_bottom).offset(8); }]; [self.bubbleView mas_remakeConstraints:^(MASConstraintMaker *make) { make.width.lessThanOrEqualTo(self.contentView.mas_width).multipliedBy(0.65); if (outgoing) { make.top.equalTo(self.contentView.mas_top).offset(6); make.bottom.equalTo(self.contentView.mas_bottom).offset(-6); make.right.equalTo(self.avatarView.mas_left).offset(-6); } else { // AI 消息:气泡在语音按钮下方 make.top.equalTo(self.voiceButton.mas_bottom).offset(4); make.bottom.equalTo(self.contentView.mas_bottom).offset(-6); make.left.equalTo(self.avatarView.mas_right).offset(6); make.right.lessThanOrEqualTo(self.contentView.mas_right).offset(-12); } }]; if (audioMessage) { [self.messageLabel mas_remakeConstraints:^(MASConstraintMaker *make) { make.width.height.mas_equalTo(0); make.left.equalTo(self.bubbleView.mas_left); make.top.equalTo(self.bubbleView.mas_top); }]; [self.audioIconView mas_remakeConstraints:^(MASConstraintMaker *make) { make.left.equalTo(self.bubbleView.mas_left).offset(10); make.centerY.equalTo(self.bubbleView); make.width.height.mas_equalTo(16); }]; [self.audioLabel mas_remakeConstraints:^(MASConstraintMaker *make) { make.left.equalTo(self.audioIconView.mas_right).offset(6); make.centerY.equalTo(self.bubbleView); make.right.equalTo(self.bubbleView.mas_right).offset(-10); make.top.greaterThanOrEqualTo(self.bubbleView.mas_top).offset(8); make.bottom.lessThanOrEqualTo(self.bubbleView.mas_bottom).offset(-8); }]; } else { [self.audioIconView mas_remakeConstraints:^(MASConstraintMaker *make) { make.width.height.mas_equalTo(0); make.left.equalTo(self.bubbleView.mas_left); make.top.equalTo(self.bubbleView.mas_top); }]; [self.audioLabel mas_remakeConstraints:^(MASConstraintMaker *make) { make.width.height.mas_equalTo(0); make.left.equalTo(self.audioIconView.mas_right); make.top.equalTo(self.bubbleView.mas_top); }]; [self.messageLabel mas_remakeConstraints:^(MASConstraintMaker *make) { make.edges.equalTo(self.bubbleView).insets(UIEdgeInsetsMake(8, 10, 8, 10)); }]; } } #pragma mark - Typewriter Effect - (void)kb_startTypewriterEffectWithText:(NSString *)text { if (text.length == 0) return; self.fullText = text; self.currentCharIndex = 0; // 先设置完整文本让布局计算正确高度 self.messageLabel.text = text; [self.contentView setNeedsLayout]; [self.contentView layoutIfNeeded]; // 应用打字机效果 dispatch_async(dispatch_get_main_queue(), ^{ 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; self.typewriterTimer = [NSTimer scheduledTimerWithTimeInterval:0.03 target:self selector:@selector(kb_typewriterTick) userInfo:nil repeats:YES]; [[NSRunLoop currentRunLoop] addTimer:self.typewriterTimer forMode:NSRunLoopCommonModes]; [self kb_typewriterTick]; }); } - (void)kb_typewriterTick { NSString *text = self.fullText; if (!text || text.length == 0) { [self kb_stopTypewriterEffect]; return; } if (self.currentCharIndex < text.length) { self.currentCharIndex++; NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text]; UIColor *textColor = self.messageLabel.textColor ?: [UIColor blackColor]; if (self.currentCharIndex > 0) { [attributedText addAttribute:NSForegroundColorAttributeName value:textColor 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; } else { [self kb_stopTypewriterEffect]; // 显示完整文本 NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text]; UIColor *textColor = self.messageLabel.textColor ?: [UIColor blackColor]; [attributedText addAttribute:NSForegroundColorAttributeName value:textColor range:NSMakeRange(0, text.length)]; [attributedText addAttribute:NSFontAttributeName value:self.messageLabel.font range:NSMakeRange(0, text.length)]; self.messageLabel.attributedText = attributedText; // 标记完成 if (self.currentMessage) { self.currentMessage.isComplete = YES; self.currentMessage.needsTypewriterEffect = NO; } } } - (void)kb_stopTypewriterEffect { if (self.typewriterTimer && self.typewriterTimer.isValid) { [self.typewriterTimer invalidate]; } self.typewriterTimer = nil; self.currentCharIndex = 0; self.fullText = nil; } #pragma mark - Voice Button - (void)kb_updateVoicePlayingState:(BOOL)isPlaying { UIImage *icon = nil; if (@available(iOS 13.0, *)) { icon = isPlaying ? [UIImage systemImageNamed:@"pause.circle.fill"] : [UIImage systemImageNamed:@"play.circle.fill"]; } [self.voiceButton setImage:icon forState:UIControlStateNormal]; } - (void)kb_showVoiceLoadingAnimation { [self.voiceButton setImage:nil forState:UIControlStateNormal]; [self.voiceLoadingIndicator startAnimating]; } - (void)kb_hideVoiceLoadingAnimation { [self.voiceLoadingIndicator stopAnimating]; UIImage *icon = nil; if (@available(iOS 13.0, *)) { icon = [UIImage systemImageNamed:@"play.circle.fill"]; } [self.voiceButton setImage:icon forState:UIControlStateNormal]; } - (void)kb_onVoiceButtonTapped { if ([self.delegate respondsToSelector:@selector(chatMessageCell:didTapVoiceButtonForMessage:)]) { [self.delegate chatMessageCell:self didTapVoiceButtonForMessage:self.currentMessage]; } } #pragma mark - Reuse - (void)prepareForReuse { [super prepareForReuse]; [self kb_stopTypewriterEffect]; self.messageLabel.text = @""; self.messageLabel.attributedText = nil; [self.messageLoadingIndicator stopAnimating]; [self.voiceLoadingIndicator stopAnimating]; } - (void)dealloc { [self kb_stopTypewriterEffect]; } #pragma mark - Lazy - (UIImageView *)avatarView { if (!_avatarView) { _avatarView = [[UIImageView alloc] init]; _avatarView.contentMode = UIViewContentModeScaleAspectFill; _avatarView.layer.cornerRadius = 14; _avatarView.layer.masksToBounds = YES; _avatarView.backgroundColor = [UIColor colorWithWhite:0.9 alpha:1.0]; _avatarView.tintColor = [UIColor kb_dynamicColorWithLightColor:[UIColor colorWithHex:0xB9BDC8] darkColor:[UIColor colorWithHex:0x6B6F7A]]; } return _avatarView; } - (UILabel *)nameLabel { if (!_nameLabel) { _nameLabel = [[UILabel alloc] init]; _nameLabel.font = [UIFont systemFontOfSize:11]; _nameLabel.textColor = [UIColor colorWithHex:0x6B6F7A]; _nameLabel.numberOfLines = 1; } return _nameLabel; } - (UIView *)bubbleView { if (!_bubbleView) { _bubbleView = [[UIView alloc] init]; _bubbleView.layer.cornerRadius = 12; _bubbleView.layer.masksToBounds = YES; } return _bubbleView; } - (UILabel *)messageLabel { if (!_messageLabel) { _messageLabel = [[UILabel alloc] init]; _messageLabel.font = [UIFont systemFontOfSize:14]; _messageLabel.numberOfLines = 0; } return _messageLabel; } - (UIImageView *)audioIconView { if (!_audioIconView) { _audioIconView = [[UIImageView alloc] init]; _audioIconView.contentMode = UIViewContentModeScaleAspectFit; _audioIconView.tintColor = [UIColor colorWithHex:0x1B1F1A]; UIImage *icon = nil; if (@available(iOS 13.0, *)) { icon = [UIImage systemImageNamed:@"waveform"]; } _audioIconView.image = icon; } return _audioIconView; } - (UILabel *)audioLabel { if (!_audioLabel) { _audioLabel = [[UILabel alloc] init]; _audioLabel.font = [UIFont systemFontOfSize:14 weight:UIFontWeightMedium]; _audioLabel.numberOfLines = 1; } return _audioLabel; } - (UIButton *)voiceButton { if (!_voiceButton) { _voiceButton = [UIButton buttonWithType:UIButtonTypeCustom]; UIImage *icon = nil; if (@available(iOS 13.0, *)) { icon = [UIImage systemImageNamed:@"play.circle.fill"]; } [_voiceButton setImage:icon forState:UIControlStateNormal]; _voiceButton.tintColor = [UIColor whiteColor]; [_voiceButton addTarget:self action:@selector(kb_onVoiceButtonTapped) forControlEvents:UIControlEventTouchUpInside]; } return _voiceButton; } - (UILabel *)durationLabel { if (!_durationLabel) { _durationLabel = [[UILabel alloc] init]; _durationLabel.font = [UIFont systemFontOfSize:11]; _durationLabel.textColor = [UIColor whiteColor]; } return _durationLabel; } - (UIActivityIndicatorView *)voiceLoadingIndicator { if (!_voiceLoadingIndicator) { _voiceLoadingIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium]; _voiceLoadingIndicator.color = [UIColor whiteColor]; _voiceLoadingIndicator.hidesWhenStopped = YES; } return _voiceLoadingIndicator; } - (UIActivityIndicatorView *)messageLoadingIndicator { if (!_messageLoadingIndicator) { _messageLoadingIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium]; _messageLoadingIndicator.color = [UIColor whiteColor]; _messageLoadingIndicator.hidesWhenStopped = YES; } return _messageLoadingIndicator; } - (UIImage *)kb_defaultAvatarImage { if (@available(iOS 13.0, *)) { return [UIImage systemImageNamed:@"person.circle.fill"]; } return nil; } @end