处理键盘
This commit is contained in:
@@ -8,12 +8,31 @@
|
||||
#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
|
||||
@@ -26,6 +45,10 @@
|
||||
|
||||
[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];
|
||||
@@ -35,6 +58,11 @@
|
||||
}
|
||||
|
||||
- (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];
|
||||
@@ -50,7 +78,6 @@
|
||||
self.messageLabel.textColor = textColor;
|
||||
self.audioLabel.textColor = textColor;
|
||||
self.audioIconView.tintColor = textColor;
|
||||
self.messageLabel.text = message.text ?: @"";
|
||||
self.audioLabel.text =
|
||||
(message.text.length > 0) ? message.text : KBLocalized(@"语音回复");
|
||||
self.messageLabel.hidden = audioMessage;
|
||||
@@ -69,6 +96,43 @@
|
||||
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);
|
||||
@@ -85,13 +149,45 @@
|
||||
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);
|
||||
@@ -100,7 +196,8 @@
|
||||
make.bottom.equalTo(self.contentView.mas_bottom).offset(-6);
|
||||
make.right.equalTo(self.avatarView.mas_left).offset(-6);
|
||||
} else {
|
||||
make.top.equalTo(self.nameLabel.mas_bottom).offset(2);
|
||||
// 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);
|
||||
@@ -142,6 +239,144 @@
|
||||
}
|
||||
}
|
||||
|
||||
#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 {
|
||||
@@ -209,6 +444,47 @@
|
||||
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"];
|
||||
|
||||
Reference in New Issue
Block a user