mirror of
https://github.com/TencentCloud/TIMSDK.git
synced 2026-10-21 00:36:58 +08:00
341 lines
12 KiB
Objective-C
341 lines
12 KiB
Objective-C
//
|
|
// TUIChatbotMessageCell.m
|
|
// TUIChat
|
|
//
|
|
// Created by Yiliang Wang on 2025/5/30.
|
|
// Copyright © 2023 Tencent. All rights reserved.
|
|
//
|
|
|
|
#import "TUIChatbotMessageCell.h"
|
|
#import <TIMCommon/TIMDefine.h>
|
|
#import <TUICore/TUICore.h>
|
|
#import <ImageIO/ImageIO.h>
|
|
#import <SDWebImage/SDWebImage.h>
|
|
|
|
@interface TUIChatbotMessageCell ()
|
|
@property(nonatomic, strong) UIImageView *loadingImageView;
|
|
@property(nonatomic, strong) CADisplayLink *animationDisplayLink;
|
|
@property(nonatomic, assign) NSInteger currentFrameIndex;
|
|
@property(nonatomic, strong) NSArray<UIImage *> *animationFrames;
|
|
@property(nonatomic, strong) dispatch_source_t currentTimer;
|
|
@end
|
|
|
|
@implementation TUIChatbotMessageCell
|
|
|
|
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
|
|
self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
|
|
if (self) {
|
|
// Add notification listener for immediate stop rendering
|
|
self.textView.selectable = NO;
|
|
[[NSNotificationCenter defaultCenter] addObserver:self
|
|
selector:@selector(onImmediateStopRendering:)
|
|
name:@"TUIChatbotImmediateStopRendering"
|
|
object:nil];
|
|
}
|
|
return self;
|
|
}
|
|
|
|
- (void)prepareForReuse {
|
|
[super prepareForReuse];
|
|
|
|
// Reset loading image view state to prevent cell reuse issues
|
|
[self stopLoadingAnimation];
|
|
if (_loadingImageView) {
|
|
_loadingImageView.hidden = YES;
|
|
_loadingImageView.image = nil;
|
|
}
|
|
|
|
}
|
|
|
|
- (void)onImmediateStopRendering:(NSNotification *)notification {
|
|
// Check if this notification is for this specific cell
|
|
TUIMessageCellData *targetCellData = notification.userInfo[@"cellData"];
|
|
if (targetCellData && targetCellData == self.textData) {
|
|
[self immediateStopRendering];
|
|
}
|
|
}
|
|
|
|
- (void)immediateStopRendering {
|
|
// Immediately stop timer
|
|
if (self.currentTimer) {
|
|
[self stopTimer:self.currentTimer];
|
|
self.currentTimer = nil;
|
|
|
|
// Also clear data.timer if it matches current timer
|
|
TUIChatbotMessageCellData *data = (TUIChatbotMessageCellData *)self.textData;
|
|
if (data && [data isKindOfClass:[TUIChatbotMessageCellData class]]) {
|
|
data.timer = nil;
|
|
}
|
|
}
|
|
|
|
// Stop loading animation
|
|
[self stopLoadingAnimation];
|
|
|
|
// Hide loading image
|
|
self.loadingImageView.hidden = YES;
|
|
|
|
NSLog(@"TUIChatbotMessageCell: Immediate stop rendering executed");
|
|
}
|
|
|
|
- (void)setupLoadingImageView {
|
|
if (!_loadingImageView) {
|
|
_loadingImageView = [[UIImageView alloc] init];
|
|
_loadingImageView.contentMode = UIViewContentModeScaleAspectFit;
|
|
_loadingImageView.hidden = YES;
|
|
[self.textView addSubview:_loadingImageView];
|
|
|
|
// Load GIF animation frames
|
|
[self loadAnimationFrames];
|
|
}
|
|
}
|
|
|
|
- (void)loadAnimationFrames {
|
|
|
|
NSString *gifPath = TUIChatImagePath(@"chat_ai_loading.gif");
|
|
if (!gifPath) {
|
|
// If GIF not found, create simple loading animation
|
|
[self createSimpleLoadingAnimation];
|
|
return;
|
|
}
|
|
|
|
UIImage *image = [UIImage sd_imageWithGIFData:[NSData dataWithContentsOfFile:gifPath]];
|
|
if (image && image.images.count > 0) {
|
|
self.animationFrames = image.images;
|
|
} else {
|
|
[self createSimpleLoadingAnimation];
|
|
}
|
|
}
|
|
|
|
- (void)createSimpleLoadingAnimation {
|
|
// Create simple dot animation frames
|
|
NSMutableArray *frames = [NSMutableArray array];
|
|
NSArray *dotTexts = @[@"●", @"●●", @"●●●"];
|
|
|
|
for (NSString *text in dotTexts) {
|
|
UIGraphicsBeginImageContextWithOptions(CGSizeMake(20, 20), NO, [UIScreen mainScreen].scale);
|
|
|
|
[text drawInRect:CGRectMake(0, 0, 20, 20)
|
|
withAttributes:@{
|
|
NSFontAttributeName: [UIFont systemFontOfSize:12],
|
|
NSForegroundColorAttributeName: [UIColor lightGrayColor]
|
|
}];
|
|
|
|
UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
|
|
UIGraphicsEndImageContext();
|
|
|
|
if (image) {
|
|
[frames addObject:image];
|
|
}
|
|
}
|
|
|
|
self.animationFrames = [frames copy];
|
|
}
|
|
|
|
- (void)startLoadingAnimation {
|
|
if (self.animationFrames.count == 0) return;
|
|
|
|
[self stopLoadingAnimation];
|
|
|
|
self.currentFrameIndex = 0;
|
|
self.animationDisplayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(updateAnimationFrame)];
|
|
self.animationDisplayLink.preferredFramesPerSecond = 3; // 3 FPS for loading animation
|
|
[self.animationDisplayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
|
|
}
|
|
|
|
- (void)stopLoadingAnimation {
|
|
if (self.animationDisplayLink) {
|
|
[self.animationDisplayLink invalidate];
|
|
self.animationDisplayLink = nil;
|
|
}
|
|
}
|
|
|
|
- (void)updateAnimationFrame {
|
|
if (self.animationFrames.count == 0) return;
|
|
|
|
self.currentFrameIndex = (self.currentFrameIndex + 1) % self.animationFrames.count;
|
|
self.loadingImageView.image = self.animationFrames[self.currentFrameIndex];
|
|
}
|
|
|
|
- (void)updateLoadingImageViewLayout {
|
|
if (!_loadingImageView || _loadingImageView.hidden) return;
|
|
|
|
// Get current displayed text length
|
|
NSString *displayedText = self.textView.text;
|
|
if (displayedText.length == 0) return;
|
|
|
|
// Calculate actual text rendering position
|
|
NSLayoutManager *layoutManager = self.textView.layoutManager;
|
|
NSTextContainer *textContainer = self.textView.textContainer;
|
|
|
|
// Get position of the last character
|
|
NSRange lastCharRange = NSMakeRange(displayedText.length - 1, 1);
|
|
CGRect lastCharRect = [layoutManager boundingRectForGlyphRange:lastCharRange inTextContainer:textContainer];
|
|
|
|
// Set loading image position to the right of the last character
|
|
CGFloat imageSize = 16.0;
|
|
CGFloat imageX = lastCharRect.origin.x + lastCharRect.size.width + 4; // 4px spacing after last character
|
|
CGFloat imageY = lastCharRect.origin.y + (lastCharRect.size.height - imageSize) / 2; // Vertically centered
|
|
|
|
// Convert to textView coordinate system
|
|
CGFloat textViewX = self.textView.textContainerInset.left + imageX;
|
|
CGFloat textViewY = self.textView.textContainerInset.top + imageY;
|
|
|
|
_loadingImageView.frame = CGRectMake(textViewX, textViewY, imageSize, imageSize);
|
|
if (isRTL()) {
|
|
[_loadingImageView resetFrameToFitRTL];
|
|
}
|
|
}
|
|
|
|
- (void)fillWithData:(TUIChatbotMessageCellData *)data {
|
|
[super fillWithData:data];
|
|
|
|
// Setup loading image view
|
|
[self setupLoadingImageView];
|
|
|
|
// Always handle loading image visibility based on isFinished state
|
|
if (data.isFinished) {
|
|
[self stopLoadingAnimation];
|
|
_loadingImageView.hidden = YES;
|
|
_loadingImageView.image = nil;
|
|
} else {
|
|
[self startLoadingAnimation];
|
|
_loadingImageView.hidden = NO;
|
|
}
|
|
// Online Push text needs streaming display
|
|
if (Msg_Source_OnlinePush == data.source) {
|
|
// Reuse existing timer from data.timer or create new one
|
|
if (data.timer) {
|
|
// Reuse existing timer
|
|
self.currentTimer = data.timer;
|
|
} else {
|
|
// Create new timer only if data.timer is nil
|
|
self.currentTimer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_main_queue());
|
|
data.timer = self.currentTimer;
|
|
|
|
NSTimeInterval period = 0.04;
|
|
dispatch_time_t start = dispatch_time(DISPATCH_TIME_NOW, period * NSEC_PER_SEC);
|
|
dispatch_source_set_timer(self.currentTimer, start, period * NSEC_PER_SEC, 0 * NSEC_PER_SEC);
|
|
dispatch_source_set_event_handler(self.currentTimer, ^{
|
|
if (data.displayedContentLength == data.contentString.length) {
|
|
[self stopTimer:self.currentTimer];
|
|
self.currentTimer = nil;
|
|
data.timer = nil;
|
|
if (data.isFinished) {
|
|
[self stopLoadingAnimation];
|
|
self.loadingImageView.hidden = YES;
|
|
}
|
|
return;
|
|
}
|
|
data.displayedContentLength++;
|
|
|
|
if (self.textView.attributedText.length > 1 &&
|
|
[self getAttributeStringRect:self.textView.attributedText].size.height >
|
|
[self getAttributeStringRect:[self.textView.attributedText attributedSubstringFromRange:
|
|
NSMakeRange(0, self.textView.attributedText.length - 1)]].size.height) {
|
|
[self stopTimer:self.currentTimer];
|
|
self.currentTimer = nil;
|
|
data.timer = nil;
|
|
[self notifyCellSizeChanged];
|
|
} else {
|
|
UIColor *textColor = self.class.incommingTextColor;
|
|
UIFont *textFont = self.class.incommingTextFont;
|
|
if (data.direction == MsgDirectionIncoming) {
|
|
textColor = self.class.incommingTextColor;
|
|
textFont = self.class.incommingTextFont;
|
|
} else {
|
|
textColor = self.class.outgoingTextColor;
|
|
textFont = self.class.outgoingTextFont;
|
|
}
|
|
|
|
// Use original content display method
|
|
self.textView.attributedText = [data getContentAttributedString:textFont];
|
|
self.textView.textColor = textColor;
|
|
self.textView.textAlignment = isRTL()?NSTextAlignmentRight:NSTextAlignmentLeft;
|
|
|
|
[self updateCellConstraints];
|
|
|
|
// Update loading image position - follow text dynamically
|
|
[self updateLoadingImageViewLayout];
|
|
|
|
// Always keep loading animation visible during streaming process
|
|
if (!self.animationDisplayLink) {
|
|
[self startLoadingAnimation];
|
|
}
|
|
self.loadingImageView.hidden = NO;
|
|
}
|
|
});
|
|
dispatch_resume(self.currentTimer);
|
|
}
|
|
} else {
|
|
// Non-streaming display handling
|
|
UIFont *textFont = self.class.incommingTextFont;
|
|
if (data.direction == MsgDirectionOutgoing) {
|
|
textFont = self.class.outgoingTextFont;
|
|
}
|
|
self.textView.attributedText = [data getContentAttributedString:textFont];
|
|
self.textView.textColor = self.class.incommingTextColor;
|
|
self.textView.textAlignment = isRTL()?NSTextAlignmentRight:NSTextAlignmentLeft;
|
|
|
|
// Only update layout if loading image should be visible
|
|
if (!data.isFinished) {
|
|
[self updateLoadingImageViewLayout];
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
- (void)layoutSubviews {
|
|
[super layoutSubviews];
|
|
[self updateLoadingImageViewLayout];
|
|
}
|
|
|
|
- (void)dealloc {
|
|
[self stopLoadingAnimation];
|
|
|
|
// Stop current timer
|
|
if (self.currentTimer) {
|
|
[self stopTimer:self.currentTimer];
|
|
self.currentTimer = nil;
|
|
|
|
// Also clear data.timer
|
|
TUIChatbotMessageCellData *data = (TUIChatbotMessageCellData *)self.textData;
|
|
if (data && [data isKindOfClass:[TUIChatbotMessageCellData class]]) {
|
|
data.timer = nil;
|
|
}
|
|
}
|
|
|
|
// Remove notification observer
|
|
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
|
}
|
|
|
|
- (CGRect)getAttributeStringRect:(NSAttributedString *)attributeString {
|
|
return [attributeString boundingRectWithSize:CGSizeMake(TTextMessageCell_Text_Width_Max, MAXFLOAT)
|
|
options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading context:nil];
|
|
}
|
|
|
|
- (void)stopTimer:(dispatch_source_t)timer {
|
|
if (timer) {
|
|
dispatch_source_cancel(timer);
|
|
timer = nil;
|
|
}
|
|
}
|
|
|
|
- (void)notifyCellSizeChanged {
|
|
NSDictionary *param = @{TUICore_TUIPluginNotify_PluginViewSizeChangedSubKey_Message : self.textData.innerMessage};
|
|
[TUICore notifyEvent:TUICore_TUIPluginNotify
|
|
subKey:TUICore_TUIPluginNotify_PluginViewSizeChangedSubKey
|
|
object:nil
|
|
param:param];
|
|
}
|
|
|
|
- (void)updateCellConstraints {
|
|
// tell constraints they need updating
|
|
[self setNeedsUpdateConstraints];
|
|
// update constraints now so we can animate the change
|
|
[self updateConstraintsIfNeeded];
|
|
[self layoutIfNeeded];
|
|
}
|
|
|
|
@end
|
|
|