Files
keyboard/CustomKeyboard/View/KBChatMessageCell.m
2026-01-30 13:17:11 +08:00

496 lines
19 KiB
Objective-C
Raw Permalink 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.

//
// 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