Files
keyboard/CustomKeyboard/View/KBChatMessageCell.m

496 lines
19 KiB
Mathematica
Raw Normal View History

2026-01-15 18:16:56 +08:00
//
// KBChatMessageCell.m
// CustomKeyboard
//
#import "KBChatMessageCell.h"
#import "KBChatMessage.h"
#import "Masonry.h"
@interface KBChatMessageCell ()
2026-01-30 13:17:11 +08:00
2026-01-15 18:16:56 +08:00
@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;
2026-01-30 13:17:11 +08:00
///
@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;
2026-01-15 18:16:56 +08:00
@end
@implementation KBChatMessageCell
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
if (self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]) {
self.backgroundColor = [UIColor clearColor];
2026-01-15 18:49:31 +08:00
self.contentView.backgroundColor = [UIColor clearColor];
2026-01-15 18:16:56 +08:00
self.selectionStyle = UITableViewCellSelectionStyleNone;
[self.contentView addSubview:self.avatarView];
[self.contentView addSubview:self.nameLabel];
2026-01-30 13:17:11 +08:00
[self.contentView addSubview:self.voiceButton];
[self.contentView addSubview:self.durationLabel];
[self.contentView addSubview:self.voiceLoadingIndicator];
[self.contentView addSubview:self.messageLoadingIndicator];
2026-01-15 18:16:56 +08:00
[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 {
2026-01-30 13:17:11 +08:00
//
[self kb_stopTypewriterEffect];
self.currentMessage = message;
2026-01-15 18:16:56 +08:00
BOOL outgoing = message.outgoing;
BOOL audioMessage = (!outgoing && message.audioFilePath.length > 0);
UIColor *bubbleColor = outgoing ? [UIColor colorWithHex:0x02BEAC] : [UIColor colorWithWhite:1 alpha:0.95];
2026-01-15 19:14:34 +08:00
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]];
2026-01-15 18:16:56 +08:00
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;
2026-01-15 19:14:34 +08:00
self.nameLabel.textColor = nameColor;
2026-01-15 18:16:56 +08:00
self.nameLabel.text =
(message.displayName.length > 0) ? message.displayName : KBLocalized(@"AI助手");
2026-01-30 13:17:11 +08:00
// 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 {
2026-01-15 18:16:56 +08:00
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);
}];
2026-01-30 13:17:11 +08:00
//
[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);
}];
2026-01-15 18:16:56 +08:00
} 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);
}];
2026-01-30 13:17:11 +08:00
// 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);
}];
2026-01-15 18:16:56 +08:00
}
2026-01-30 13:17:11 +08:00
//
[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);
}];
2026-01-15 18:16:56 +08:00
[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 {
2026-01-30 13:17:11 +08:00
// AI
make.top.equalTo(self.voiceButton.mas_bottom).offset(4);
2026-01-15 18:16:56 +08:00
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) {
2026-01-15 20:30:03 +08:00
[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);
}];
2026-01-15 18:16:56 +08:00
[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 {
2026-01-15 20:30:03 +08:00
[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);
}];
2026-01-15 18:16:56 +08:00
[self.messageLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
make.edges.equalTo(self.bubbleView).insets(UIEdgeInsetsMake(8, 10, 8, 10));
}];
}
}
2026-01-30 13:17:11 +08:00
#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];
}
2026-01-15 18:16:56 +08:00
#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];
2026-01-15 19:14:34 +08:00
_avatarView.tintColor =
[UIColor kb_dynamicColorWithLightColor:[UIColor colorWithHex:0xB9BDC8]
darkColor:[UIColor colorWithHex:0x6B6F7A]];
2026-01-15 18:16:56 +08:00
}
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;
}
2026-01-30 13:17:11 +08:00
- (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;
}
2026-01-15 18:16:56 +08:00
- (UIImage *)kb_defaultAvatarImage {
if (@available(iOS 13.0, *)) {
return [UIImage systemImageNamed:@"person.circle.fill"];
}
return nil;
}
@end